xo-harness 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +92 -5
  2. package/dist/browser/worklets/capture-processor.js +34 -0
  3. package/dist/browser/worklets/playback-processor.js +188 -0
  4. package/dist/browser.d.ts +1 -0
  5. package/dist/browser.js +1 -0
  6. package/dist/internal/browser/browser-voice-client.d.ts +33 -0
  7. package/dist/internal/browser/browser-voice-client.js +316 -0
  8. package/dist/internal/browser/index.d.ts +2 -0
  9. package/dist/internal/browser/index.js +1 -0
  10. package/dist/internal/harness/conversation-context.d.ts +45 -0
  11. package/dist/internal/harness/conversation-context.js +79 -0
  12. package/dist/internal/harness/conversation-projection.d.ts +55 -0
  13. package/dist/internal/harness/conversation-projection.js +145 -0
  14. package/dist/internal/harness/event-stream.d.ts +23 -2
  15. package/dist/internal/harness/event-stream.js +148 -18
  16. package/dist/internal/harness/index.d.ts +7 -1
  17. package/dist/internal/harness/index.js +7 -1
  18. package/dist/internal/harness/message.d.ts +24 -11
  19. package/dist/internal/harness/message.js +271 -77
  20. package/dist/internal/harness/report-diff.d.ts +3 -0
  21. package/dist/internal/harness/report-diff.js +10 -0
  22. package/dist/internal/harness/report.d.ts +17 -14
  23. package/dist/internal/harness/report.js +41 -24
  24. package/dist/internal/harness/runtime-limits.d.ts +18 -0
  25. package/dist/internal/harness/runtime-limits.js +19 -0
  26. package/dist/internal/harness/session-persistence.d.ts +16 -0
  27. package/dist/internal/harness/session-persistence.js +106 -0
  28. package/dist/internal/harness/shadow.d.ts +5 -5
  29. package/dist/internal/harness/shadow.js +214 -52
  30. package/dist/internal/harness/socket-bridge.d.ts +45 -4
  31. package/dist/internal/harness/socket-bridge.js +215 -46
  32. package/dist/internal/harness/task-supervisor.d.ts +6 -2
  33. package/dist/internal/harness/task-supervisor.js +97 -15
  34. package/dist/internal/harness/tool-calls.d.ts +40 -0
  35. package/dist/internal/harness/tool-calls.js +132 -0
  36. package/dist/internal/harness/tool-policy.d.ts +2 -2
  37. package/dist/internal/harness/tool-runtime.d.ts +2 -2
  38. package/dist/internal/harness/tool-runtime.js +37 -40
  39. package/dist/internal/harness/tools.d.ts +21 -0
  40. package/dist/internal/harness/tools.js +7 -1
  41. package/dist/internal/harness/usage-tracker.d.ts +39 -0
  42. package/dist/internal/harness/usage-tracker.js +104 -0
  43. package/dist/internal/harness/voice-session.d.ts +39 -8
  44. package/dist/internal/harness/voice-session.js +353 -58
  45. package/dist/internal/harness/xo.d.ts +7 -12
  46. package/dist/internal/harness/xo.js +25 -13
  47. package/dist/internal/protocol/async-queue.d.ts +22 -1
  48. package/dist/internal/protocol/async-queue.js +86 -12
  49. package/dist/internal/protocol/audio.d.ts +16 -2
  50. package/dist/internal/protocol/audio.js +23 -5
  51. package/dist/internal/protocol/backend-output.d.ts +70 -0
  52. package/dist/internal/protocol/backend-output.js +39 -0
  53. package/dist/internal/protocol/event-json.d.ts +3 -0
  54. package/dist/internal/protocol/event-json.js +15 -0
  55. package/dist/internal/protocol/events.d.ts +342 -4
  56. package/dist/internal/protocol/events.js +53 -25
  57. package/dist/internal/protocol/index.d.ts +5 -0
  58. package/dist/internal/protocol/index.js +5 -0
  59. package/dist/internal/protocol/output-source.d.ts +18 -0
  60. package/dist/internal/protocol/output-source.js +15 -0
  61. package/dist/internal/protocol/paced-audio.d.ts +27 -0
  62. package/dist/internal/protocol/paced-audio.js +117 -0
  63. package/dist/internal/protocol/parts.d.ts +154 -0
  64. package/dist/internal/protocol/parts.js +32 -6
  65. package/dist/internal/protocol/provider.d.ts +308 -1
  66. package/dist/internal/protocol/provider.js +102 -3
  67. package/dist/internal/protocol/records.d.ts +773 -0
  68. package/dist/internal/protocol/records.js +46 -0
  69. package/dist/internal/protocol/tools.d.ts +40 -0
  70. package/dist/internal/protocol/tools.js +12 -0
  71. package/dist/internal/protocol/transcript.d.ts +19 -0
  72. package/dist/internal/protocol/transcript.js +53 -0
  73. package/dist/internal/provider/contract.d.ts +25 -2
  74. package/dist/internal/provider/event-queue.d.ts +17 -0
  75. package/dist/internal/provider/event-queue.js +78 -0
  76. package/dist/internal/provider/grok-voice.d.ts +12 -9
  77. package/dist/internal/provider/grok-voice.js +26 -15
  78. package/dist/internal/provider/index.d.ts +1 -0
  79. package/dist/internal/provider/index.js +1 -0
  80. package/dist/internal/provider/live-session.d.ts +29 -0
  81. package/dist/internal/provider/live-session.js +840 -0
  82. package/dist/internal/provider/node-socket.js +6 -0
  83. package/dist/internal/provider/openai-live.d.ts +62 -0
  84. package/dist/internal/provider/openai-live.js +160 -0
  85. package/dist/internal/provider/openai-realtime.d.ts +9 -5
  86. package/dist/internal/provider/openai-realtime.js +24 -10
  87. package/dist/internal/provider/realtime-session.d.ts +12 -1
  88. package/dist/internal/provider/realtime-session.js +373 -70
  89. package/dist/internal/provider/realtime-socket.d.ts +3 -1
  90. package/dist/internal/provider/tool-status-context.d.ts +10 -0
  91. package/dist/internal/provider/tool-status-context.js +30 -0
  92. package/dist/internal/provider/workers-socket.js +63 -15
  93. package/dist/internal/provider/workers.d.ts +1 -0
  94. package/dist/internal/provider/workers.js +1 -0
  95. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
  96. package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
  97. package/dist/internal/storage/event-store.d.ts +22 -4
  98. package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
  99. package/dist/internal/storage/jsonl-event-store.js +21 -16
  100. package/dist/internal/storage/memory-event-store.d.ts +4 -3
  101. package/dist/internal/storage/memory-event-store.js +8 -2
  102. package/dist/internal/storage/memory.d.ts +1 -1
  103. package/dist/internal/testkit/events.d.ts +14 -0
  104. package/dist/internal/testkit/events.js +33 -0
  105. package/dist/internal/testkit/runtime.d.ts +3 -0
  106. package/dist/internal/testkit/runtime.js +3 -0
  107. package/dist/internal/testkit/trajectory.d.ts +23 -0
  108. package/dist/internal/testkit/trajectory.js +58 -0
  109. package/dist/internal/tools-openai/index.d.ts +2 -0
  110. package/dist/internal/tools-openai/index.js +79 -72
  111. package/dist/internal/tools-openai/responses.d.ts +5 -1
  112. package/dist/internal/tools-openai/responses.js +53 -5
  113. package/dist/testing.d.ts +1 -0
  114. package/dist/testing.js +1 -0
  115. package/package.json +7 -1
@@ -1,27 +1,41 @@
1
- import { AssistantTurnRequestSchema, AsyncQueue, AudioChunkSchema, audioSampleCount, PlayoutProgressSchema, PROVIDER_DIAGNOSTIC_MAX_CONTENT_TYPES, PROVIDER_DIAGNOSTIC_MAX_OUTPUTS, PROVIDER_DIAGNOSTIC_STRING_MAX_LENGTH, ProviderContextEventSchema, ProviderToolResultSchema, ToolCallSchema, } from "../protocol/index.js";
1
+ import { AssistantTurnRequestSchema, AudioChunkSchema, audioSampleCount, PlayoutProgressSchema, PROVIDER_DIAGNOSTIC_MAX_CONTENT_TYPES, PROVIDER_DIAGNOSTIC_MAX_OUTPUTS, PROVIDER_DIAGNOSTIC_STRING_MAX_LENGTH, ProviderContextEventSchema, ProviderToolResultSchema, ToolCallSchema, } from "../protocol/index.js";
2
2
  import { z } from "zod";
3
+ import { ProviderEventQueue } from "./event-queue.js";
4
+ import { toolStatusContext } from "./tool-status-context.js";
3
5
  const FALLBACK_STREAM_ID = "assistant-output";
4
6
  const CLOSE_REASON_BYTE_LIMIT = 120;
5
7
  const EMITTED_USER_TRANSCRIPT_CACHE_LIMIT = 128;
8
+ const TOOL_RESULT_CORRELATION_LIMIT = 4096;
6
9
  const VAD_RESPONSE_RECOVERY_MS = 1_500;
7
10
  const ServerEnvelopeSchema = z.looseObject({ type: z.string().min(1) });
8
- const OutputAudioDeltaSchema = z.looseObject({
11
+ const OutputCoordinatesSchema = z.object({
9
12
  item_id: z.string().min(1).optional(),
10
13
  response_id: z.string().min(1).optional(),
14
+ output_index: z.int().nonnegative().optional(),
15
+ content_index: z.int().nonnegative().optional(),
16
+ });
17
+ const OutputAudioDeltaSchema = z.looseObject({
18
+ ...OutputCoordinatesSchema.shape,
11
19
  delta: z.string().min(1).optional(),
12
20
  data: z.string().min(1).optional(),
13
21
  });
14
22
  const FunctionCallItemSchema = z.looseObject({
15
23
  type: z.literal("function_call"),
24
+ id: z.string().min(1).optional(),
16
25
  call_id: z.string().min(1),
17
26
  name: z.string().min(1),
18
27
  arguments: z.string(),
19
28
  });
20
29
  const FunctionCallArgumentsDoneSchema = z.looseObject({
30
+ ...OutputCoordinatesSchema.shape,
21
31
  call_id: z.string().min(1),
22
32
  name: z.string().min(1),
23
33
  arguments: z.string(),
24
34
  });
35
+ const OutputItemDoneSchema = z.looseObject({
36
+ ...OutputCoordinatesSchema.shape,
37
+ item: FunctionCallItemSchema,
38
+ });
25
39
  const UsageDetailsSchema = z.looseObject({
26
40
  text_tokens: z.number().int().nonnegative().optional(),
27
41
  audio_tokens: z.number().int().nonnegative().optional(),
@@ -55,19 +69,18 @@ const ResponseDiagnosticSchema = z.looseObject({
55
69
  status_details: ResponseStatusDetailsSchema.nullish(),
56
70
  output: z.array(ResponseOutputDiagnosticSchema).max(PROVIDER_DIAGNOSTIC_MAX_OUTPUTS).optional(),
57
71
  });
58
- /** Core response.done fields stay independent from optional diagnostic metadata. */
72
+ const ResponseUsageSchema = z.looseObject({
73
+ total_tokens: z.number().int().nonnegative(),
74
+ input_tokens: z.number().int().nonnegative(),
75
+ output_tokens: z.number().int().nonnegative(),
76
+ input_token_details: UsageDetailsSchema.optional(),
77
+ output_token_details: UsageDetailsSchema.optional(),
78
+ });
79
+ /** Optional usage and diagnostics must never decide whether a function completed. */
59
80
  const ResponseDoneSchema = z.looseObject({
60
81
  response: z.looseObject({
61
82
  output: z.array(z.looseObject({ type: z.string().optional() })).optional(),
62
- usage: z
63
- .looseObject({
64
- total_tokens: z.number().int().nonnegative(),
65
- input_tokens: z.number().int().nonnegative(),
66
- output_tokens: z.number().int().nonnegative(),
67
- input_token_details: UsageDetailsSchema.optional(),
68
- output_token_details: UsageDetailsSchema.optional(),
69
- })
70
- .optional(),
83
+ usage: z.unknown().optional(),
71
84
  }),
72
85
  });
73
86
  const ResponseEnvelopeSchema = z.looseObject({
@@ -79,18 +92,23 @@ const ResponseIdEnvelopeSchema = z.looseObject({
79
92
  });
80
93
  const TranscriptEventSchema = z.looseObject({
81
94
  transcript: z.string(),
82
- item_id: z.string().min(1).optional(),
95
+ ...OutputCoordinatesSchema.shape,
96
+ });
97
+ const InputTranscriptionFailedSchema = z.looseObject({
98
+ item_id: z.string().min(1),
99
+ content_index: z.int().nonnegative(),
100
+ error: z.looseObject({ message: z.string().nullish(), code: z.string().nullish() }),
83
101
  });
84
102
  const OutputTextDoneSchema = z.looseObject({
85
103
  text: z.string(),
86
- item_id: z.string().min(1).optional(),
104
+ ...OutputCoordinatesSchema.shape,
87
105
  });
88
106
  const TranscriptDeltaWireSchema = z.looseObject({
89
107
  delta: z.string(),
90
- item_id: z.string().min(1).optional(),
108
+ ...OutputCoordinatesSchema.shape,
91
109
  });
92
110
  const ServerErrorSchema = z.looseObject({
93
- error: z.looseObject({ message: z.string().optional() }).optional(),
111
+ error: z.looseObject({ message: z.string().optional(), event_id: z.string().min(1).nullish() }).optional(),
94
112
  });
95
113
  const SessionUpdatedSchema = z.looseObject({
96
114
  session: z
@@ -116,12 +134,20 @@ const SpeechBoundarySchema = z.looseObject({
116
134
  export class RealtimeVoiceSession {
117
135
  #socket;
118
136
  #wire;
119
- #queue = new AsyncQueue();
137
+ #signal;
138
+ #onAbort = () => {
139
+ void this.close(String(this.#signal.reason ?? "aborted"));
140
+ };
141
+ #queue;
120
142
  #outputStreams = new Map();
121
143
  #playedThrough = new Map();
122
144
  #interruptedStreams = new Set();
145
+ #interruptedResponseIds = new Set();
123
146
  #deliveredToolCalls = new Set();
124
- #completedResponseIds = new Set();
147
+ #rejectedToolCalls = new Set();
148
+ #backgroundCalls = new Map();
149
+ #toolResultEvents = new Map();
150
+ #completedResponses = new Map();
125
151
  #usageResponseIds = new Set();
126
152
  #pendingUserTranscripts = new Map();
127
153
  #emittedUserTranscripts = new Map();
@@ -131,6 +157,8 @@ export class RealtimeVoiceSession {
131
157
  #responseNumber = 0;
132
158
  #responseActive = false;
133
159
  #activeResponseId;
160
+ #pendingResponseEventId;
161
+ #clientEventSequence = 0;
134
162
  #responseWanted = false;
135
163
  #inputSpeechActive = false;
136
164
  #awaitingVadResponse = false;
@@ -139,26 +167,49 @@ export class RealtimeVoiceSession {
139
167
  #wantedResponseInstructions;
140
168
  #ready = false;
141
169
  #closed = false;
170
+ #settling = false;
142
171
  #closeReason;
143
172
  constructor(socket, wire, options) {
144
173
  this.#socket = socket;
145
174
  this.#wire = wire;
175
+ this.#signal = options.signal;
146
176
  this.#outputSampleRate = wire.outputSampleRate;
147
177
  this.#settled = new Promise((resolve) => {
148
178
  this.#resolveSettled = resolve;
149
179
  });
180
+ this.#queue = new ProviderEventQueue(wire.eventQueueLimits, () => {
181
+ this.#settle("provider_event_queue_failed");
182
+ if (socket.state !== "closed") {
183
+ if (socket.terminate)
184
+ socket.terminate();
185
+ else
186
+ socket.close(1011, "provider_event_queue_failed");
187
+ }
188
+ });
150
189
  socket.onMessage = (text) => this.#handleMessage(text);
151
190
  socket.onError = (message) => this.#emitError(message, false);
152
191
  socket.onClose = (code) => this.#settle(this.#closeReason ?? `socket_closed_${code}`);
153
- options.signal.addEventListener("abort", () => {
154
- void this.close(String(options.signal.reason ?? "aborted"));
155
- }, { once: true });
192
+ this.#signal.addEventListener("abort", this.#onAbort, { once: true });
193
+ if (this.#signal.aborted) {
194
+ this.#onAbort();
195
+ return;
196
+ }
156
197
  this.#send({ type: "session.update", session: wire.sessionUpdate });
157
198
  }
199
+ get failureSignal() {
200
+ return this.#queue.failureSignal;
201
+ }
202
+ eventQueueStats() {
203
+ return this.#queue.stats();
204
+ }
158
205
  get events() {
159
206
  return this.#queue;
160
207
  }
161
208
  async sendAudio(chunk) {
209
+ this.failureSignal.throwIfAborted();
210
+ if (this.#wire.autoCommitAudio === false) {
211
+ throw new Error(`${this.#wire.label} cannot accept audio with turnDetection: null; XO does not expose manual input-audio commit. Enable turn detection, optionally with create_response: false for host-triggered replies.`);
212
+ }
162
213
  const validated = AudioChunkSchema.parse(chunk);
163
214
  if (validated.channels !== 1) {
164
215
  throw new Error(`${this.#wire.label} expects mono PCM input`);
@@ -169,6 +220,7 @@ export class RealtimeVoiceSession {
169
220
  this.#send({ type: "input_audio_buffer.append", audio: toBase64(validated.data) });
170
221
  }
171
222
  async sendText(text) {
223
+ this.failureSignal.throwIfAborted();
172
224
  this.#send({
173
225
  type: "conversation.item.create",
174
226
  item: { type: "message", role: "user", content: [{ type: "input_text", text }] },
@@ -176,38 +228,110 @@ export class RealtimeVoiceSession {
176
228
  this.#requestResponse();
177
229
  }
178
230
  async requestAssistantTurn(request = {}) {
231
+ this.failureSignal.throwIfAborted();
179
232
  const validated = AssistantTurnRequestSchema.parse(request);
180
233
  this.#requestResponse(validated.instructions);
181
234
  }
182
235
  async reportPlayout(progress) {
236
+ this.failureSignal.throwIfAborted();
183
237
  const validated = PlayoutProgressSchema.parse(progress);
184
- this.#playedThrough.set(validated.streamId, validated.playedThroughSample);
238
+ this.#playedThrough.set(validated.streamId, Math.max(this.#playedThrough.get(validated.streamId) ?? 0, validated.playedThroughSample));
185
239
  }
186
240
  async sendToolResult(result) {
241
+ this.failureSignal.throwIfAborted();
187
242
  const validated = ProviderToolResultSchema.parse(result);
188
- this.#send({
189
- type: "conversation.item.create",
190
- item: {
191
- type: "function_call_output",
192
- call_id: validated.callId,
193
- output: toolOutcomePayload(validated.outcome),
194
- },
195
- });
243
+ if (this.#wire.nativeAsyncTools && validated.outcome.type === "accepted_task") {
244
+ const { taskId } = validated.outcome;
245
+ const existing = this.#backgroundCalls.get(validated.callId);
246
+ if (existing) {
247
+ if (existing.taskId !== taskId)
248
+ throw new Error("A background tool call cannot be assigned to a different task");
249
+ return;
250
+ }
251
+ if ([...this.#backgroundCalls.values()].some((call) => call.taskId === taskId))
252
+ throw new Error("A background task cannot belong to a different tool call");
253
+ // A task handle is acceptance, not the result of the original function call.
254
+ // Keeping that call open lets native async-tool reasoning recognize pending work.
255
+ this.#sendContextItem(JSON.stringify({
256
+ type: "accepted_task",
257
+ callId: validated.callId,
258
+ taskId,
259
+ note: "The original function call remains pending. Its final output will arrive automatically; continue independent work while waiting.",
260
+ }));
261
+ this.#backgroundCalls.set(validated.callId, { taskId, settled: false });
262
+ this.#requestResponse();
263
+ return;
264
+ }
265
+ this.#sendFunctionResult(validated.callId, toolOutcomePayload(validated.outcome));
196
266
  this.#requestResponse();
197
267
  }
268
+ #sendFunctionResult(callId, output) {
269
+ const eventId = this.#wire.correlateClientErrors
270
+ ? `xo-realtime-tool-result-${++this.#clientEventSequence}`
271
+ : undefined;
272
+ if (eventId !== undefined)
273
+ this.#toolResultEvents.set(eventId, callId);
274
+ try {
275
+ this.#send({
276
+ type: "conversation.item.create",
277
+ ...(eventId === undefined ? {} : { event_id: eventId }),
278
+ item: {
279
+ type: "function_call_output",
280
+ call_id: callId,
281
+ output,
282
+ },
283
+ });
284
+ }
285
+ catch (error) {
286
+ if (eventId !== undefined)
287
+ this.#toolResultEvents.delete(eventId);
288
+ throw error;
289
+ }
290
+ // Retain recent submissions for asynchronous rejections without an unbounded
291
+ // per-session command log. An error never retries the action or its output.
292
+ if (this.#toolResultEvents.size > TOOL_RESULT_CORRELATION_LIMIT) {
293
+ const oldest = this.#toolResultEvents.keys().next().value;
294
+ if (oldest !== undefined)
295
+ this.#toolResultEvents.delete(oldest);
296
+ }
297
+ }
198
298
  async sendContext(event) {
299
+ this.failureSignal.throwIfAborted();
199
300
  const validated = ProviderContextEventSchema.parse(event);
301
+ if (validated.type !== "tool.status") {
302
+ const background = this.#backgroundCalls.get(validated.callId);
303
+ if (!background && [...this.#backgroundCalls.values()].some((call) => call.taskId === validated.taskId))
304
+ throw new Error("Task context does not match the accepted background tool call");
305
+ if (background) {
306
+ if (background.taskId !== validated.taskId)
307
+ throw new Error("Task context does not match the accepted background tool call");
308
+ if (background.settled)
309
+ return;
310
+ if (validated.type !== "task.progress") {
311
+ this.#sendFunctionResult(validated.callId, taskResultPayload(validated));
312
+ background.settled = true;
313
+ if (this.#wire.announceTaskSettlement)
314
+ this.#requestResponse();
315
+ return;
316
+ }
317
+ }
318
+ }
319
+ this.#sendContextItem(contextText(validated));
320
+ if (validated.type !== "tool.status" &&
321
+ validated.type !== "task.progress" &&
322
+ this.#wire.announceTaskSettlement) {
323
+ this.#requestResponse();
324
+ }
325
+ }
326
+ #sendContextItem(text) {
200
327
  this.#send({
201
328
  type: "conversation.item.create",
202
329
  item: {
203
330
  type: "message",
204
331
  role: "system",
205
- content: [{ type: "input_text", text: contextText(validated) }],
332
+ content: [{ type: "input_text", text }],
206
333
  },
207
334
  });
208
- if (validated.type !== "task.progress" && this.#wire.announceTaskSettlement) {
209
- this.#requestResponse();
210
- }
211
335
  }
212
336
  async close(reason = "client_closed") {
213
337
  this.#closeReason ??= reason;
@@ -222,6 +346,8 @@ export class RealtimeVoiceSession {
222
346
  await this.#settled;
223
347
  }
224
348
  #handleMessage(text) {
349
+ if (this.#closed)
350
+ return;
225
351
  let parsed;
226
352
  try {
227
353
  parsed = JSON.parse(text);
@@ -241,8 +367,19 @@ export class RealtimeVoiceSession {
241
367
  break;
242
368
  case "response.created":
243
369
  this.#handleResponseCreated(parsed);
370
+ this.#pendingResponseEventId = undefined;
244
371
  this.#responseActive = true;
245
372
  this.#activeResponseId = responseIdFromEnvelope(parsed);
373
+ // A response.create already in flight can arrive after the server's VAD start.
374
+ // Automatic interruption only cancelled responses that existed at that boundary.
375
+ if (this.#inputSpeechActive && this.#awaitingVadResponse && this.#wire.interruptOnSpeech !== false) {
376
+ if (this.#activeResponseId !== undefined)
377
+ this.#interruptedResponseIds.add(this.#activeResponseId);
378
+ this.#trySend({
379
+ type: "response.cancel",
380
+ ...(this.#activeResponseId === undefined ? {} : { response_id: this.#activeResponseId }),
381
+ });
382
+ }
246
383
  if (!this.#inputSpeechActive)
247
384
  this.#clearAwaitingVadResponse();
248
385
  this.#reportResponseState();
@@ -266,9 +403,19 @@ export class RealtimeVoiceSession {
266
403
  case "conversation.item.input_audio_transcription.completed":
267
404
  this.#handleTranscript(parsed, "user");
268
405
  break;
406
+ case "conversation.item.input_audio_transcription.failed": {
407
+ const failed = InputTranscriptionFailedSchema.safeParse(parsed);
408
+ this.#emitError(failed.success
409
+ ? `Input audio transcription failed for ${failed.data.item_id} content ${failed.data.content_index}: ${failed.data.error.message ?? failed.data.error.code ?? "Unknown transcription error"}`
410
+ : "Received a malformed input audio transcription failure", true);
411
+ break;
412
+ }
269
413
  case "response.function_call_arguments.done":
270
414
  this.#handleFunctionCallArgumentsDone(parsed);
271
415
  break;
416
+ case "response.output_item.done":
417
+ this.#handleOutputItemDone(parsed);
418
+ break;
272
419
  case "response.done":
273
420
  this.#handleResponseDone(parsed);
274
421
  break;
@@ -319,6 +466,8 @@ export class RealtimeVoiceSession {
319
466
  this.#emitError("Received a malformed output audio delta", true);
320
467
  return;
321
468
  }
469
+ if (delta.data.response_id !== undefined && this.#interruptedResponseIds.has(delta.data.response_id))
470
+ return;
322
471
  const payload = delta.data.delta ?? delta.data.data;
323
472
  if (payload === undefined) {
324
473
  this.#emitError("Received an output audio delta without an audio payload", true);
@@ -336,9 +485,11 @@ export class RealtimeVoiceSession {
336
485
  this.#emitError(`Received a PCM16 delta with invalid byte length ${bytes.byteLength}`, true);
337
486
  return;
338
487
  }
339
- const streamId = delta.data.item_id ??
488
+ const baseStreamId = delta.data.item_id ??
340
489
  delta.data.response_id ??
341
490
  (this.#responseNumber === 0 ? FALLBACK_STREAM_ID : `${FALLBACK_STREAM_ID}-${this.#responseNumber}`);
491
+ const streamId = contentStreamId(baseStreamId, delta.data.content_index);
492
+ const source = outputSource(delta.data);
342
493
  if (this.#interruptedStreams.has(streamId))
343
494
  return;
344
495
  const stream = this.#outputStreams.get(streamId) ?? {
@@ -346,6 +497,8 @@ export class RealtimeVoiceSession {
346
497
  startSample: 0,
347
498
  complete: false,
348
499
  itemId: delta.data.item_id,
500
+ responseId: delta.data.response_id ?? this.#activeResponseId,
501
+ contentIndex: delta.data.content_index ?? 0,
349
502
  };
350
503
  const chunk = AudioChunkSchema.parse({
351
504
  streamId,
@@ -359,20 +512,22 @@ export class RealtimeVoiceSession {
359
512
  stream.sequence += 1;
360
513
  stream.startSample += audioSampleCount(chunk);
361
514
  this.#outputStreams.set(streamId, stream);
362
- this.#emit({ type: "audio.output", chunk });
515
+ this.#emit({ type: "audio.output", chunk, ...(source === undefined ? {} : { source }) });
363
516
  }
364
517
  #handleTranscript(parsed, role) {
365
518
  const transcript = TranscriptEventSchema.safeParse(parsed);
366
519
  if (!transcript.success)
367
520
  return;
521
+ const source = outputSource(transcript.data);
522
+ const streamId = transcriptStreamId(transcript.data);
368
523
  if (role === "user" && transcript.data.item_id !== undefined) {
369
- this.#queueUserTranscript(transcript.data.item_id, transcript.data.transcript);
524
+ this.#queueUserTranscript(streamId ?? transcript.data.item_id, transcript.data.transcript, source);
370
525
  return;
371
526
  }
372
- this.#emitTranscript(role, transcript.data.transcript, transcript.data.item_id);
527
+ this.#emitTranscript(role, transcript.data.transcript, streamId, source);
373
528
  }
374
529
  /** xAI can send several "completed" revisions for one input item; emit only the settled text. */
375
- #queueUserTranscript(streamId, rawText) {
530
+ #queueUserTranscript(streamId, rawText, source) {
376
531
  const text = rawText.trim();
377
532
  if (text === "")
378
533
  return;
@@ -380,7 +535,11 @@ export class RealtimeVoiceSession {
380
535
  if (pending)
381
536
  clearTimeout(pending.timer);
382
537
  const timer = setTimeout(() => this.#flushUserTranscript(streamId), this.#wire.inputTranscriptSettleMs ?? 0);
383
- this.#pendingUserTranscripts.set(streamId, { text, timer });
538
+ this.#pendingUserTranscripts.set(streamId, {
539
+ text,
540
+ source: source === undefined ? pending?.source : { ...pending?.source, ...source },
541
+ timer,
542
+ });
384
543
  if (!this.#wire.inputTranscriptSettleMs)
385
544
  this.#flushUserTranscript(streamId);
386
545
  }
@@ -399,7 +558,7 @@ export class RealtimeVoiceSession {
399
558
  if (oldestStreamId !== undefined)
400
559
  this.#emittedUserTranscripts.delete(oldestStreamId);
401
560
  }
402
- this.#emitTranscript("user", pending.text, streamId);
561
+ this.#emitTranscript("user", pending.text, streamId, pending.source);
403
562
  }
404
563
  #flushUserTranscripts() {
405
564
  for (const streamId of [...this.#pendingUserTranscripts.keys()])
@@ -409,46 +568,86 @@ export class RealtimeVoiceSession {
409
568
  const delta = TranscriptDeltaWireSchema.safeParse(parsed);
410
569
  if (!delta.success || delta.data.delta === "")
411
570
  return;
571
+ const source = outputSource(delta.data);
572
+ const streamId = transcriptStreamId(delta.data);
412
573
  this.#emit({
413
574
  type: "transcript.delta",
414
575
  role,
415
576
  delta: delta.data.delta,
416
- ...(delta.data.item_id === undefined ? {} : { streamId: delta.data.item_id }),
577
+ ...(streamId === undefined ? {} : { streamId }),
578
+ ...(source === undefined ? {} : { source }),
417
579
  });
418
580
  }
419
581
  #handleOutputText(parsed) {
420
582
  const output = OutputTextDoneSchema.safeParse(parsed);
421
583
  if (!output.success)
422
584
  return;
423
- this.#emitTranscript("assistant", output.data.text, output.data.item_id);
585
+ this.#emitTranscript("assistant", output.data.text, transcriptStreamId(output.data), outputSource(output.data));
424
586
  }
425
- #emitTranscript(role, rawText, streamId) {
587
+ #emitTranscript(role, rawText, streamId, source) {
426
588
  const text = rawText.trim();
427
589
  if (text === "")
428
590
  return;
429
- this.#emit({ type: "transcript", role, text, ...(streamId === undefined ? {} : { streamId }) });
591
+ this.#emit({
592
+ type: "transcript",
593
+ role,
594
+ text,
595
+ ...(streamId === undefined ? {} : { streamId }),
596
+ ...(source === undefined ? {} : { source }),
597
+ });
430
598
  }
431
599
  #handleFunctionCallArgumentsDone(parsed) {
600
+ // OpenAI also emits this for cancelled/incomplete calls. Its authoritative
601
+ // item/response completion carries full arguments, so no speculative buffer is needed.
602
+ if (this.#wire.toolCallCompletion !== "arguments")
603
+ return;
432
604
  const done = FunctionCallArgumentsDoneSchema.safeParse(parsed);
433
605
  if (!done.success)
434
606
  return;
435
- this.#emitFunctionCall(done.data.call_id, done.data.name, done.data.arguments);
607
+ this.#emitFunctionCall(done.data.call_id, done.data.name, done.data.arguments, outputSource(done.data));
608
+ }
609
+ #handleOutputItemDone(parsed) {
610
+ const done = OutputItemDoneSchema.safeParse(parsed);
611
+ if (!done.success)
612
+ return;
613
+ const item = done.data.item;
614
+ if (item.status !== undefined && item.status !== "completed") {
615
+ this.#rejectToolCall(item.call_id);
616
+ return;
617
+ }
618
+ if (item.status !== "completed" && this.#wire.toolCallCompletion === "confirmed")
619
+ return;
620
+ this.#emitFunctionCall(item.call_id, item.name, item.arguments, outputSource({
621
+ ...done.data,
622
+ ...(item.id === undefined ? {} : { item_id: item.id }),
623
+ }));
436
624
  }
437
625
  #handleResponseDone(parsed) {
438
626
  this.#emitResponseEnvelopeDiagnostic(parsed, "completed");
439
627
  const responseId = responseIdFromEnvelope(parsed);
440
- const duplicate = responseId !== undefined && this.#completedResponseIds.has(responseId);
441
- if (responseId !== undefined)
442
- this.#completedResponseIds.add(responseId);
628
+ const duplicate = responseId !== undefined && this.#completedResponses.has(responseId);
443
629
  const done = ResponseDoneSchema.safeParse(parsed);
630
+ const status = done.success ? done.data.response.status : undefined;
631
+ const completed = done.success &&
632
+ (status === "completed" || (status === undefined && this.#wire.toolCallCompletion === "arguments"));
633
+ if (responseId !== undefined && !duplicate)
634
+ this.#completedResponses.set(responseId, completed);
444
635
  if (done.success) {
445
636
  const usage = done.data.response.usage;
446
- if (usage && (responseId === undefined || !this.#usageResponseIds.has(responseId))) {
447
- this.#emitUsage(usage);
448
- if (responseId !== undefined)
449
- this.#usageResponseIds.add(responseId);
637
+ if (usage !== undefined && (responseId === undefined || !this.#usageResponseIds.has(responseId))) {
638
+ const parsedUsage = ResponseUsageSchema.safeParse(usage);
639
+ if (parsedUsage.success) {
640
+ this.#emitUsage(parsedUsage.data);
641
+ if (responseId !== undefined)
642
+ this.#usageResponseIds.add(responseId);
643
+ }
644
+ else {
645
+ this.#emitError("Received incomplete or invalid response usage; token accounting remains unconfirmed", true);
646
+ }
450
647
  }
451
- for (const item of done.data.response.output ?? []) {
648
+ for (const [outputIndex, item] of (done.data.response.output ?? []).entries()) {
649
+ if (duplicate)
650
+ break;
452
651
  if (item.type !== "function_call")
453
652
  continue;
454
653
  const call = FunctionCallItemSchema.safeParse(item);
@@ -456,7 +655,15 @@ export class RealtimeVoiceSession {
456
655
  this.#emitError("Received a function call item without call_id, name, or arguments", true);
457
656
  continue;
458
657
  }
459
- this.#emitFunctionCall(call.data.call_id, call.data.name, call.data.arguments);
658
+ if (!completed || (call.data.status !== undefined && call.data.status !== "completed")) {
659
+ this.#rejectToolCall(call.data.call_id);
660
+ continue;
661
+ }
662
+ this.#emitFunctionCall(call.data.call_id, call.data.name, call.data.arguments, outputSource({
663
+ output_index: outputIndex,
664
+ ...(responseId === undefined ? {} : { response_id: responseId }),
665
+ ...(call.data.id === undefined ? {} : { item_id: call.data.id }),
666
+ }));
460
667
  }
461
668
  }
462
669
  // A duplicate or stale terminal update can refine diagnostics and carry omitted
@@ -469,6 +676,7 @@ export class RealtimeVoiceSession {
469
676
  return;
470
677
  this.#responseActive = false;
471
678
  this.#activeResponseId = undefined;
679
+ this.#pendingResponseEventId = undefined;
472
680
  // Generation often ends well before the client finishes playing its queued PCM.
473
681
  for (const stream of this.#outputStreams.values())
474
682
  stream.complete = true;
@@ -496,25 +704,44 @@ export class RealtimeVoiceSession {
496
704
  : { cachedAudioInputTokens: input.cached_tokens_details.audio_tokens }),
497
705
  });
498
706
  }
499
- #emitFunctionCall(callId, name, rawArguments) {
500
- if (this.#deliveredToolCalls.has(callId))
707
+ #rejectToolCall(callId) {
708
+ if (!this.#deliveredToolCalls.has(callId))
709
+ this.#rejectedToolCalls.add(callId);
710
+ }
711
+ #emitFunctionCall(callId, name, rawArguments, source) {
712
+ if (this.#closed || this.#deliveredToolCalls.has(callId) || this.#rejectedToolCalls.has(callId))
713
+ return;
714
+ if (source?.responseId !== undefined &&
715
+ (this.#completedResponses.get(source.responseId) === false ||
716
+ this.#interruptedResponseIds.has(source.responseId)))
501
717
  return;
502
718
  let parsedArguments;
503
719
  try {
504
720
  parsedArguments = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
721
+ if (parsedArguments === null || typeof parsedArguments !== "object" || Array.isArray(parsedArguments))
722
+ throw new Error("Function arguments must be a valid JSON object");
505
723
  }
506
724
  catch {
507
- this.#emitError(`Could not parse arguments for tool call ${name}`, true);
508
- return;
509
- }
510
- if (parsedArguments === null || typeof parsedArguments !== "object" || Array.isArray(parsedArguments)) {
511
- this.#emitError(`Tool call ${name} arguments are not a JSON object`, true);
725
+ // This is confirmed native intent, even though it cannot be admitted to the
726
+ // harness. Settle it once so the model can recover instead of waiting forever.
727
+ this.#rejectToolCall(callId);
728
+ const error = "Function arguments must be a valid JSON object";
729
+ this.#emitError(`Tool call ${name} (${callId}) was not executed: ${error}`, true);
730
+ // Finish the incoming response's bookkeeping before scheduling its recovery.
731
+ void Promise.resolve()
732
+ .then(() => this.sendToolResult({ callId, outcome: { type: "failed", error } }))
733
+ .catch((error) => this.#emitError(`Could not return invalid tool arguments: ${String(error)}`, true));
512
734
  return;
513
735
  }
514
736
  this.#deliveredToolCalls.add(callId);
515
737
  this.#emit({
516
738
  type: "tool.call",
517
- call: ToolCallSchema.parse({ callId, name, arguments: parsedArguments }),
739
+ call: ToolCallSchema.parse({
740
+ callId,
741
+ name,
742
+ arguments: parsedArguments,
743
+ ...(source === undefined ? {} : { source }),
744
+ }),
518
745
  });
519
746
  }
520
747
  #handleSpeechStarted(parsed) {
@@ -525,6 +752,8 @@ export class RealtimeVoiceSession {
525
752
  this.#reportResponseState();
526
753
  if (this.#wire.interruptOnSpeech === false)
527
754
  return;
755
+ if (this.#activeResponseId !== undefined)
756
+ this.#interruptedResponseIds.add(this.#activeResponseId);
528
757
  // A response can contain a preamble and an answer, both ahead of playback.
529
758
  // Discard queued items first so stopping the audible item cannot start the next.
530
759
  for (const [streamId, stream] of [...this.#outputStreams].reverse()) {
@@ -534,14 +763,24 @@ export class RealtimeVoiceSession {
534
763
  if (stream.complete && playedThroughSample >= stream.startSample)
535
764
  continue;
536
765
  this.#interruptedStreams.add(streamId);
766
+ if (stream.responseId !== undefined)
767
+ this.#interruptedResponseIds.add(stream.responseId);
537
768
  this.#emit({ type: "audio.interrupted", streamId });
538
- // Only a real conversation item ID is valid in the vendor's truncate command.
539
- if (stream.itemId === undefined)
769
+ // Legacy clients never acknowledge playback. Preserve their conversation
770
+ // context instead of mistaking unknown playback for zero heard samples.
771
+ // Once a client reports playback, other queued streams can start at zero.
772
+ if (stream.itemId === undefined || this.#playedThrough.size === 0)
540
773
  continue;
774
+ if (stream.contentIndex !== 0) {
775
+ // The public truncate contract currently permits only content_index 0.
776
+ // Discard locally without truncating a different content part on the server.
777
+ this.#emitError(`Interrupted audio content ${stream.contentIndex} locally; native truncation only supports content index 0`, true);
778
+ continue;
779
+ }
541
780
  this.#trySend({
542
781
  type: "conversation.item.truncate",
543
782
  item_id: stream.itemId,
544
- content_index: 0,
783
+ content_index: stream.contentIndex,
545
784
  audio_end_ms: Math.floor((Math.min(playedThroughSample, stream.startSample) / this.#outputSampleRate) * 1000),
546
785
  });
547
786
  }
@@ -595,8 +834,22 @@ export class RealtimeVoiceSession {
595
834
  const error = ServerErrorSchema.safeParse(parsed);
596
835
  const message = error.success ? error.data.error?.message : undefined;
597
836
  this.#emitError(message ?? `${this.#wire.label} reported an error`, true);
598
- if (this.#awaitingVadResponse && !this.#inputSpeechActive) {
599
- this.#clearAwaitingVadResponse();
837
+ const eventId = error.success ? error.data.error?.event_id : undefined;
838
+ const callId = eventId == null ? undefined : this.#toolResultEvents.get(eventId);
839
+ if (callId !== undefined && eventId != null) {
840
+ this.#toolResultEvents.delete(eventId);
841
+ this.#emit({
842
+ type: "tool.delivery_failed",
843
+ callId,
844
+ error: message ?? `${this.#wire.label} rejected the function result`,
845
+ });
846
+ }
847
+ if (eventId != null && eventId === this.#pendingResponseEventId) {
848
+ // A rejected create has no response.done to release the scheduler. Only its
849
+ // correlated error may clear this latch; accepted responses still own it.
850
+ this.#pendingResponseEventId = undefined;
851
+ this.#responseActive = false;
852
+ // Do not retry the rejected request; only resume a separately queued turn.
600
853
  this.#resumeQueuedResponse();
601
854
  }
602
855
  }
@@ -619,14 +872,21 @@ export class RealtimeVoiceSession {
619
872
  }
620
873
  this.#responseWanted = false;
621
874
  this.#responseActive = true;
875
+ this.#pendingResponseEventId = this.#wire.correlateClientErrors
876
+ ? `xo-realtime-response-${++this.#clientEventSequence}`
877
+ : undefined;
622
878
  this.#reportResponseState();
623
879
  this.#trySend({
624
880
  type: "response.create",
881
+ ...(this.#pendingResponseEventId === undefined ? {} : { event_id: this.#pendingResponseEventId }),
625
882
  ...(instructions === undefined ? {} : { response: { instructions } }),
626
883
  });
627
884
  }
628
885
  #armVadResponseRecovery() {
629
886
  this.#clearVadResponseRecoveryTimer();
887
+ // The preceding speech diagnostic can synchronously fail the ingress queue.
888
+ if (this.#closed)
889
+ return;
630
890
  this.#vadResponseRecoveryTimer = setTimeout(() => {
631
891
  this.#vadResponseRecoveryTimer = undefined;
632
892
  if (this.#closed || !this.#awaitingVadResponse)
@@ -663,6 +923,7 @@ export class RealtimeVoiceSession {
663
923
  this.#emit({ type: "response.state", state });
664
924
  }
665
925
  #send(payload) {
926
+ this.failureSignal.throwIfAborted();
666
927
  if (this.#socket.state !== "open") {
667
928
  throw new Error(`${this.#wire.label} session socket is not open`);
668
929
  }
@@ -670,7 +931,7 @@ export class RealtimeVoiceSession {
670
931
  }
671
932
  /** For sends triggered by incoming events: a closing socket is settlement, not an error. */
672
933
  #trySend(payload) {
673
- if (this.#socket.state !== "open")
934
+ if (this.#closed || this.failureSignal.aborted || this.#socket.state !== "open")
674
935
  return;
675
936
  this.#send(payload);
676
937
  }
@@ -683,9 +944,12 @@ export class RealtimeVoiceSession {
683
944
  this.#emit({ type: "error", message, recoverable });
684
945
  }
685
946
  #settle(reason) {
686
- if (this.#closed)
947
+ if (this.#closed || this.#settling)
687
948
  return;
949
+ this.#settling = true;
950
+ this.#signal.removeEventListener("abort", this.#onAbort);
688
951
  this.#clearVadResponseRecoveryTimer();
952
+ this.#toolResultEvents.clear();
689
953
  this.#flushUserTranscripts();
690
954
  this.#closed = true;
691
955
  this.#queue.push({ type: "closed", reason });
@@ -693,6 +957,33 @@ export class RealtimeVoiceSession {
693
957
  this.#resolveSettled();
694
958
  }
695
959
  }
960
+ /** Native coordinates are evidence only; never fill them from the active response or a counter. */
961
+ function outputSource(coordinates) {
962
+ const { response_id, item_id, output_index, content_index } = coordinates;
963
+ if (response_id === undefined &&
964
+ item_id === undefined &&
965
+ output_index === undefined &&
966
+ content_index === undefined)
967
+ return undefined;
968
+ return {
969
+ scope: "voice",
970
+ ...(response_id === undefined ? {} : { responseId: response_id }),
971
+ ...(item_id === undefined ? {} : { itemId: item_id }),
972
+ ...(output_index === undefined ? {} : { outputIndex: output_index }),
973
+ ...(content_index === undefined ? {} : { contentIndex: content_index }),
974
+ };
975
+ }
976
+ function transcriptStreamId(coordinates) {
977
+ const base = coordinates.item_id ?? coordinates.response_id;
978
+ return base === undefined ? undefined : contentStreamId(base, coordinates.content_index);
979
+ }
980
+ /** Preserve legacy zero-index IDs; escape the namespace to keep native IDs collision-free. */
981
+ function contentStreamId(base, contentIndex = 0) {
982
+ const prefix = "xo-content:";
983
+ return contentIndex === 0 && !base.startsWith(prefix)
984
+ ? base
985
+ : `${prefix}${JSON.stringify([base, contentIndex])}`;
986
+ }
696
987
  export function toRealtimeFunctionTool(definition) {
697
988
  return {
698
989
  type: "function",
@@ -717,6 +1008,8 @@ function toolOutcomePayload(outcome) {
717
1008
  }
718
1009
  function contextText(event) {
719
1010
  switch (event.type) {
1011
+ case "tool.status":
1012
+ return toolStatusContext(event);
720
1013
  case "task.progress":
721
1014
  return `Background task ${event.taskId} progress: ${JSON.stringify(event.update)}`;
722
1015
  case "task.completed":
@@ -727,6 +1020,16 @@ function contextText(event) {
727
1020
  return `Background task ${event.taskId} was cancelled: ${event.reason}`;
728
1021
  }
729
1022
  }
1023
+ function taskResultPayload(event) {
1024
+ switch (event.type) {
1025
+ case "task.completed":
1026
+ return JSON.stringify(event.result);
1027
+ case "task.failed":
1028
+ return JSON.stringify({ status: "failed", error: event.error });
1029
+ case "task.cancelled":
1030
+ return JSON.stringify({ status: "cancelled", reason: event.reason });
1031
+ }
1032
+ }
730
1033
  function toBase64(data) {
731
1034
  let binary = "";
732
1035
  // Bound each argument list; microphone buffers need not fit the engine's call stack.