xo-harness 0.2.1 → 0.3.1

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 (119) hide show
  1. package/README.md +109 -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 +216 -49
  32. package/dist/internal/harness/task-supervisor.d.ts +6 -2
  33. package/dist/internal/harness/task-supervisor.js +97 -17
  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 +38 -43
  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 +354 -61
  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 +21 -2
  50. package/dist/internal/protocol/audio.js +56 -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/error-message.d.ts +6 -0
  54. package/dist/internal/protocol/error-message.js +8 -0
  55. package/dist/internal/protocol/event-json.d.ts +3 -0
  56. package/dist/internal/protocol/event-json.js +15 -0
  57. package/dist/internal/protocol/events.d.ts +342 -4
  58. package/dist/internal/protocol/events.js +53 -25
  59. package/dist/internal/protocol/index.d.ts +5 -0
  60. package/dist/internal/protocol/index.js +5 -0
  61. package/dist/internal/protocol/internal.d.ts +5 -0
  62. package/dist/internal/protocol/internal.js +5 -0
  63. package/dist/internal/protocol/output-source.d.ts +18 -0
  64. package/dist/internal/protocol/output-source.js +15 -0
  65. package/dist/internal/protocol/paced-audio.d.ts +27 -0
  66. package/dist/internal/protocol/paced-audio.js +117 -0
  67. package/dist/internal/protocol/parts.d.ts +154 -0
  68. package/dist/internal/protocol/parts.js +32 -6
  69. package/dist/internal/protocol/provider.d.ts +308 -1
  70. package/dist/internal/protocol/provider.js +102 -3
  71. package/dist/internal/protocol/records.d.ts +773 -0
  72. package/dist/internal/protocol/records.js +46 -0
  73. package/dist/internal/protocol/tools.d.ts +40 -0
  74. package/dist/internal/protocol/tools.js +12 -0
  75. package/dist/internal/protocol/transcript.d.ts +19 -0
  76. package/dist/internal/protocol/transcript.js +53 -0
  77. package/dist/internal/provider/contract.d.ts +25 -2
  78. package/dist/internal/provider/event-queue.d.ts +17 -0
  79. package/dist/internal/provider/event-queue.js +78 -0
  80. package/dist/internal/provider/grok-voice.d.ts +12 -9
  81. package/dist/internal/provider/grok-voice.js +26 -15
  82. package/dist/internal/provider/index.d.ts +1 -0
  83. package/dist/internal/provider/index.js +1 -0
  84. package/dist/internal/provider/live-session.d.ts +29 -0
  85. package/dist/internal/provider/live-session.js +838 -0
  86. package/dist/internal/provider/node-socket.js +7 -3
  87. package/dist/internal/provider/openai-live.d.ts +62 -0
  88. package/dist/internal/provider/openai-live.js +161 -0
  89. package/dist/internal/provider/openai-realtime.d.ts +9 -5
  90. package/dist/internal/provider/openai-realtime.js +24 -10
  91. package/dist/internal/provider/realtime-session.d.ts +12 -1
  92. package/dist/internal/provider/realtime-session.js +369 -68
  93. package/dist/internal/provider/realtime-socket.d.ts +3 -1
  94. package/dist/internal/provider/tool-status-context.d.ts +10 -0
  95. package/dist/internal/provider/tool-status-context.js +30 -0
  96. package/dist/internal/provider/workers-socket.js +63 -15
  97. package/dist/internal/provider/workers.d.ts +1 -0
  98. package/dist/internal/provider/workers.js +1 -0
  99. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
  100. package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
  101. package/dist/internal/storage/event-store.d.ts +22 -4
  102. package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
  103. package/dist/internal/storage/jsonl-event-store.js +21 -16
  104. package/dist/internal/storage/memory-event-store.d.ts +4 -3
  105. package/dist/internal/storage/memory-event-store.js +8 -2
  106. package/dist/internal/storage/memory.d.ts +1 -1
  107. package/dist/internal/testkit/events.d.ts +14 -0
  108. package/dist/internal/testkit/events.js +33 -0
  109. package/dist/internal/testkit/runtime.d.ts +3 -0
  110. package/dist/internal/testkit/runtime.js +3 -0
  111. package/dist/internal/testkit/trajectory.d.ts +23 -0
  112. package/dist/internal/testkit/trajectory.js +58 -0
  113. package/dist/internal/tools-openai/index.d.ts +2 -0
  114. package/dist/internal/tools-openai/index.js +79 -72
  115. package/dist/internal/tools-openai/responses.d.ts +5 -1
  116. package/dist/internal/tools-openai/responses.js +53 -5
  117. package/dist/testing.d.ts +1 -0
  118. package/dist/testing.js +1 -0
  119. package/package.json +7 -1
@@ -1,9 +1,10 @@
1
- import { MessageInputTextSchema, OutputModalitySchema, } from "../protocol/index.js";
1
+ import { MessageInputTextSchema, OutputModalitySchema, PlayoutRangeSchema, } from "../protocol/index.js";
2
+ import { errorMessage } from "../protocol/internal.js";
2
3
  import { z } from "zod";
3
4
  /**
4
5
  * The client wire protocol every XO transport speaks:
5
6
  * - text frames are JSON control messages (start / playout / stop up; started / event /
6
- * error / closed down),
7
+ * interrupt / error / closed down),
7
8
  * - binary frames are PCM16 mono audio (microphone up; length-prefixed model audio down:
8
9
  * u32le header length, JSON header, PCM bytes).
9
10
  *
@@ -33,6 +34,7 @@ export const VoiceClientMessageSchema = z.discriminatedUnion("type", [
33
34
  streamId: z.string().min(1),
34
35
  playedThroughSample: z.number().int().nonnegative(),
35
36
  }),
37
+ PlayoutRangeSchema.safeExtend({ type: z.literal("playout.range") }),
36
38
  /** Graceful end; the server still delivers the trailing events and a closed frame. */
37
39
  z.object({ type: z.literal("stop"), reason: z.string().min(1).optional() }),
38
40
  ]);
@@ -40,15 +42,35 @@ export const VOICE_BRIDGE_INPUT_SAMPLE_RATE = 24_000;
40
42
  export class VoiceSocketBridge {
41
43
  #socket;
42
44
  #startSession;
45
+ #onStarted;
46
+ #shouldSendEvent;
47
+ #onError;
48
+ #startupCloseTimeoutMs;
43
49
  #inputSampleRate;
50
+ #workController = new AbortController();
51
+ #startupController;
52
+ #startup;
53
+ #eventPump;
54
+ #teardownPromise;
55
+ #closeReason;
56
+ #socketFailed = false;
44
57
  #session;
45
58
  #sequence = 0;
46
59
  #startSample = 0;
47
60
  #closing = false;
48
61
  #sessionEnded = false;
62
+ #starting = false;
63
+ #ready = false;
49
64
  constructor(options) {
65
+ const timeout = options.startupCloseTimeoutMs ?? 5_000;
66
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 2_147_483_647)
67
+ throw new Error("startupCloseTimeoutMs must be a positive integer no greater than 2147483647 ms");
50
68
  this.#socket = options.socket;
51
69
  this.#startSession = options.startSession;
70
+ this.#onStarted = options.onStarted;
71
+ this.#shouldSendEvent = options.shouldSendEvent;
72
+ this.#onError = options.onError;
73
+ this.#startupCloseTimeoutMs = timeout;
52
74
  this.#inputSampleRate = options.inputSampleRate ?? VOICE_BRIDGE_INPUT_SAMPLE_RATE;
53
75
  }
54
76
  /** Feed every text frame from the client socket here. */
@@ -57,7 +79,7 @@ export class VoiceSocketBridge {
57
79
  await this.#handleControl(VoiceClientMessageSchema.parse(JSON.parse(text)));
58
80
  }
59
81
  catch (error) {
60
- this.#sendJson({ type: "error", message: errorMessage(error) });
82
+ this.#reportError(error);
61
83
  }
62
84
  }
63
85
  /** Feed every binary frame from the client socket here. */
@@ -66,36 +88,82 @@ export class VoiceSocketBridge {
66
88
  await this.#pushAudio(data);
67
89
  }
68
90
  catch (error) {
69
- this.#sendJson({ type: "error", message: errorMessage(error) });
91
+ this.#reportError(error);
70
92
  }
71
93
  }
72
- /** Call when the client socket closes; settles the session. */
73
- async handleClose(reason = "socket_closed") {
74
- await this.#teardown(reason);
94
+ /**
95
+ * Closes an attached session gracefully, or cancels pending creation and waits up to
96
+ * startupCloseTimeoutMs. Repeated calls share one result. Unconfirmed startup is still
97
+ * cleaned up if it returns later; this result never claims provider finalization.
98
+ */
99
+ handleClose(reason = "socket_closed") {
100
+ return this.#teardown(reason);
75
101
  }
76
102
  async #handleControl(message) {
77
103
  switch (message.type) {
78
104
  case "start": {
79
- if (this.#session)
105
+ if (this.#session || this.#starting)
80
106
  throw new Error("A session is already active on this connection");
107
+ if (this.#closing || this.#sessionEnded)
108
+ throw new Error("This voice connection has ended");
109
+ this.#starting = true;
81
110
  const { type: _type, ...start } = message;
82
- const session = await this.#startSession(start);
83
- this.#session = session;
84
- this.#sequence = 0;
85
- this.#startSample = 0;
86
- this.#sessionEnded = false;
87
- this.#sendJson({ type: "started", sessionId: session.id, provider: message.provider });
88
- void this.#pumpEvents(session);
111
+ const controller = new AbortController();
112
+ this.#startupController = controller;
113
+ // Assign the tracked promise before invoking host code, which can close reentrantly.
114
+ const creation = Promise.resolve()
115
+ .then(async () => {
116
+ controller.signal.throwIfAborted();
117
+ const session = await this.#startSession(start, { startupSignal: controller.signal });
118
+ // Ownership transfers at factory return, before readiness. Ordinary close must
119
+ // never abort this persistent owner signal after the session has been attached.
120
+ this.#startupController = undefined;
121
+ if (this.#closing) {
122
+ await session
123
+ .close(this.#closeReason ?? "client_disconnected_during_startup")
124
+ .catch((error) => this.#reportError(error));
125
+ }
126
+ else {
127
+ this.#session = session;
128
+ this.#eventPump = this.#pumpEvents(session, message.provider);
129
+ }
130
+ return session;
131
+ })
132
+ .finally(() => {
133
+ this.#startupController = undefined;
134
+ });
135
+ this.#startup = creation.then(() => undefined, () => undefined);
136
+ try {
137
+ const session = await creation;
138
+ if (this.#closing)
139
+ break;
140
+ await session.waitUntilReady();
141
+ if (this.#closing || this.#sessionEnded)
142
+ break;
143
+ if (!this.#announceStarted(session, message.provider))
144
+ break;
145
+ if (!this.#closing && !this.#sessionEnded)
146
+ void this.#runOnStarted(session);
147
+ }
148
+ catch (error) {
149
+ if (!this.#closing) {
150
+ void this.#teardown("startup_failed");
151
+ throw error;
152
+ }
153
+ }
154
+ finally {
155
+ this.#starting = false;
156
+ }
89
157
  break;
90
158
  }
91
159
  case "text": {
92
- if (this.#sessionEnded)
93
- break;
94
- await this.#session?.sendText(message.text);
160
+ if (!this.#ready || !this.#session || this.#sessionEnded || this.#closing)
161
+ throw new Error("Typed input requires an active, started voice session");
162
+ await this.#session.sendText(message.text);
95
163
  break;
96
164
  }
97
165
  case "playout": {
98
- if (this.#sessionEnded)
166
+ if (this.#sessionEnded || this.#closing)
99
167
  break;
100
168
  await this.#session?.reportPlayout({
101
169
  streamId: message.streamId,
@@ -103,6 +171,13 @@ export class VoiceSocketBridge {
103
171
  });
104
172
  break;
105
173
  }
174
+ case "playout.range": {
175
+ if (this.#sessionEnded || this.#closing)
176
+ break;
177
+ const { type: _type, ...range } = message;
178
+ await this.#session?.reportPlayoutRange(range);
179
+ break;
180
+ }
106
181
  case "stop": {
107
182
  await this.#teardown(message.reason ?? "client_stopped");
108
183
  break;
@@ -112,11 +187,13 @@ export class VoiceSocketBridge {
112
187
  async #pushAudio(data) {
113
188
  const session = this.#session;
114
189
  // In-flight microphone frames after settlement are expected, not an error.
115
- if (!session || this.#closing || this.#sessionEnded)
190
+ if (!session || !this.#ready || this.#closing || this.#sessionEnded)
116
191
  return;
117
- const byteLength = data.byteLength - (data.byteLength % 2);
192
+ const byteLength = data.byteLength;
118
193
  if (byteLength === 0)
119
194
  return;
195
+ if (byteLength % 2 !== 0)
196
+ throw new Error("Microphone PCM16 frames must contain complete 2-byte samples");
120
197
  const chunk = {
121
198
  streamId: "microphone",
122
199
  sequence: this.#sequence,
@@ -124,65 +201,163 @@ export class VoiceSocketBridge {
124
201
  sampleRate: this.#inputSampleRate,
125
202
  channels: 1,
126
203
  startSample: this.#startSample,
127
- data: copyBytes(data, byteLength),
204
+ data: Uint8Array.from(data),
128
205
  };
129
206
  this.#sequence += 1;
130
207
  this.#startSample += byteLength / 2;
131
208
  await session.sendAudio(chunk);
132
209
  }
133
- async #pumpEvents(session) {
210
+ async #pumpEvents(session, provider) {
134
211
  try {
135
212
  for await (const event of session.events()) {
213
+ let visible = true;
214
+ try {
215
+ if (this.#shouldSendEvent) {
216
+ visible = this.#shouldSendEvent(event);
217
+ if (typeof visible !== "boolean") {
218
+ // Observe an accidentally async callback without accepting its eventual decision.
219
+ void Promise.resolve(visible).catch(() => undefined);
220
+ throw new TypeError("shouldSendEvent must return a boolean");
221
+ }
222
+ }
223
+ }
224
+ catch (cause) {
225
+ throw new Error("Client event filter failed", { cause });
226
+ }
227
+ // Keep consuming startup events without buffering, while presenting readiness
228
+ // only after the control frame that enables the client's media/input path.
229
+ if (event.type === "provider.ready")
230
+ this.#announceStarted(session, provider);
136
231
  if (event.type === "audio.output") {
137
232
  this.#sendBinary(encodeVoiceAudioFrame(event.chunk));
138
233
  }
139
- this.#sendJson({ type: "event", event: summarizeVoiceEvent(event) });
234
+ // Playback must still stop when the application's journal visibility policy
235
+ // hides this event. The control frame carries no private source metadata.
236
+ if (event.type === "audio.interrupted" && !visible)
237
+ this.#sendJson({ type: "interrupt", streamId: event.streamId });
238
+ if (visible)
239
+ this.#sendJson({ type: "event", event: summarizeVoiceEvent(event) });
140
240
  if (event.type === "session.ended") {
141
- this.#sessionEnded = true;
142
- this.#sendJson({ type: "closed", reason: event.reason });
241
+ this.#finishClient(event.reason);
143
242
  }
144
243
  }
145
244
  }
146
245
  catch (error) {
147
- this.#sendJson({ type: "error", message: errorMessage(error) });
246
+ this.#reportError(error);
247
+ void this.#teardown("session_stream_failed");
148
248
  }
149
249
  finally {
150
250
  // However the pump exits, the connection must settle: no more media forwarding,
151
251
  // and the client always receives a closed frame exactly once.
152
- if (!this.#sessionEnded) {
153
- this.#sessionEnded = true;
154
- this.#sendJson({ type: "closed", reason: "session_stream_ended" });
155
- }
252
+ this.#finishClient("session_stream_ended");
156
253
  }
157
254
  }
158
- async #teardown(reason) {
159
- if (this.#closing)
160
- return;
255
+ #announceStarted(session, provider) {
256
+ if (this.#closing || this.#sessionEnded)
257
+ return false;
258
+ if (this.#ready)
259
+ return true;
260
+ this.#ready = true;
261
+ return this.#sendJson({
262
+ type: "started",
263
+ sessionId: session.id,
264
+ provider,
265
+ inputCadence: session.capabilities.inputCadence ?? "on-demand",
266
+ outputLifecycle: session.capabilities.outputLifecycle ?? "response",
267
+ });
268
+ }
269
+ #teardown(reason) {
270
+ if (this.#teardownPromise)
271
+ return this.#teardownPromise;
272
+ const settled = Promise.withResolvers();
273
+ this.#teardownPromise = settled.promise;
161
274
  this.#closing = true;
275
+ this.#closeReason = reason;
276
+ this.#ready = false;
277
+ this.#workController.abort(reason);
278
+ this.#startupController?.abort(reason);
279
+ void this.#performTeardown(reason).then(settled.resolve, settled.reject);
280
+ return settled.promise;
281
+ }
282
+ async #performTeardown(reason) {
162
283
  const session = this.#session;
163
284
  this.#session = undefined;
285
+ if (session) {
286
+ await session.close(reason).catch((error) => this.#reportError(error));
287
+ await this.#eventPump;
288
+ return { startup: "settled" };
289
+ }
290
+ let deadline;
164
291
  try {
165
- if (session)
166
- await session.close(reason).catch(() => undefined);
292
+ const result = this.#startup
293
+ ? await Promise.race([
294
+ this.#startup.then(() => ({ startup: "settled" })),
295
+ new Promise((resolve) => {
296
+ deadline = setTimeout(() => resolve({ startup: "unconfirmed" }), this.#startupCloseTimeoutMs);
297
+ }),
298
+ ])
299
+ : { startup: "settled" };
300
+ this.#finishClient(reason);
301
+ return result;
167
302
  }
168
303
  finally {
169
- this.#closing = false;
304
+ clearTimeout(deadline);
305
+ }
306
+ }
307
+ async #runOnStarted(session) {
308
+ try {
309
+ await this.#onStarted?.(session, { signal: this.#workController.signal });
170
310
  }
311
+ catch (error) {
312
+ if (!this.#workController.signal.aborted)
313
+ this.#reportError(error);
314
+ }
315
+ }
316
+ #finishClient(reason) {
317
+ if (this.#sessionEnded)
318
+ return;
319
+ this.#sessionEnded = true;
320
+ this.#ready = false;
321
+ this.#workController.abort(reason);
322
+ this.#sendJson({ type: "closed", reason });
323
+ }
324
+ #reportError(error) {
325
+ this.#notifyError(error);
326
+ this.#sendJson({ type: "error", message: errorMessage(error) });
327
+ }
328
+ #notifyError(error) {
329
+ try {
330
+ this.#onError?.(error instanceof Error ? error : new Error(String(error)));
331
+ }
332
+ catch {
333
+ // An observation callback must not interrupt cleanup or recursively report itself.
334
+ }
335
+ }
336
+ #socketFailure(error) {
337
+ this.#socketFailed = true;
338
+ void this.#teardown("socket_send_failed");
339
+ this.#notifyError(error);
171
340
  }
172
341
  #sendJson(payload) {
342
+ if (this.#socketFailed)
343
+ return false;
173
344
  try {
174
345
  this.#socket.sendText(JSON.stringify(payload));
346
+ return true;
175
347
  }
176
- catch {
177
- // The client socket is gone; teardown happens via handleClose.
348
+ catch (error) {
349
+ this.#socketFailure(error);
350
+ return false;
178
351
  }
179
352
  }
180
353
  #sendBinary(frame) {
354
+ if (this.#socketFailed)
355
+ return;
181
356
  try {
182
357
  this.#socket.sendBinary(frame);
183
358
  }
184
- catch {
185
- // The client socket is gone; teardown happens via handleClose.
359
+ catch (error) {
360
+ this.#socketFailure(error);
186
361
  }
187
362
  }
188
363
  }
@@ -217,11 +392,3 @@ export function summarizeVoiceEvent(event) {
217
392
  }
218
393
  return { ...event };
219
394
  }
220
- function copyBytes(data, byteLength) {
221
- const copy = new Uint8Array(byteLength);
222
- copy.set(data.subarray(0, byteLength));
223
- return copy;
224
- }
225
- function errorMessage(error) {
226
- return error instanceof Error ? error.message : String(error);
227
- }
@@ -1,5 +1,5 @@
1
1
  import type { HarnessEvent, NewHarnessEvent, ProviderContextEvent } from "../protocol/index.js";
2
- import type { BackgroundTaskLauncher } from "./tools.js";
2
+ import { type BackgroundTaskLauncher, type TaskCancellationRequestResult } from "./tools.js";
3
3
  type RecordEvent = (event: NewHarnessEvent) => Promise<HarnessEvent>;
4
4
  type SendContext = (event: ProviderContextEvent) => Promise<void>;
5
5
  type Flush = () => Promise<void>;
@@ -14,7 +14,11 @@ export declare class TaskSupervisor {
14
14
  });
15
15
  forCall(callId: string): BackgroundTaskLauncher;
16
16
  activate(taskId: string, callId: string): Promise<boolean>;
17
- cancelPending(taskId: string, callId: string, reason: string): Promise<boolean>;
17
+ /** Records and signals one cancellation request without waiting for a running task to settle. */
18
+ requestCancel(taskId: string, reason?: string): Promise<TaskCancellationRequestResult>;
19
+ cancelPending(taskId: string, callId: string, reason: string, deliverContext?: boolean): Promise<boolean>;
20
+ /** A finished tool only transfers ownership of the task ID in its accepted outcome. */
21
+ cancelUnaccepted(callId: string, acceptedTaskId?: string): Promise<void>;
18
22
  cancelAll(reason: string): Promise<void>;
19
23
  }
20
24
  export {};
@@ -1,7 +1,9 @@
1
1
  import { JsonValueSchema } from "../protocol/index.js";
2
+ import { errorMessage } from "../protocol/internal.js";
3
+ import { BackgroundTaskOutcomeSchema, } from "./tools.js";
2
4
  export class TaskSupervisor {
3
5
  #tasks = new Map();
4
- #terminalTasks = new Set();
6
+ #terminalTasks = new Map();
5
7
  #record;
6
8
  #sendContext;
7
9
  #flush;
@@ -16,7 +18,8 @@ export class TaskSupervisor {
16
18
  }
17
19
  forCall(callId) {
18
20
  return {
19
- start: (runner, options) => this.#register(callId, runner, options),
21
+ start: (runner, options) => this.#register(callId, runner, false, options),
22
+ startOutcome: (runner, options) => this.#register(callId, runner, true, options),
20
23
  };
21
24
  }
22
25
  async activate(taskId, callId) {
@@ -25,14 +28,64 @@ export class TaskSupervisor {
25
28
  await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, true);
26
29
  return false;
27
30
  }
28
- if (task.state === "running")
31
+ if (task.cancellationRequest)
32
+ await task.cancellationRequest;
33
+ if (this.#tasks.get(taskId) !== task || task.state === "running")
34
+ return false;
35
+ if (task.controller.signal.aborted) {
36
+ await this.cancelPending(taskId, callId, cancellationReason(task.controller.signal), true);
29
37
  return false;
38
+ }
30
39
  task.state = "running";
31
40
  task.promise = this.#run(taskId, task);
32
41
  void task.promise.catch(this.#onError);
33
42
  return true;
34
43
  }
35
- async cancelPending(taskId, callId, reason) {
44
+ /** Records and signals one cancellation request without waiting for a running task to settle. */
45
+ async requestCancel(taskId, reason = "cancelled") {
46
+ if (!reason.trim())
47
+ throw new Error("Task cancellation reason must not be empty");
48
+ const task = this.#tasks.get(taskId);
49
+ if (!task) {
50
+ for (const terminalId of this.#terminalTasks.values()) {
51
+ if (terminalId === taskId)
52
+ return { status: "settled" };
53
+ }
54
+ return { status: "not_found" };
55
+ }
56
+ if (this.#terminalTasks.has(taskKey(taskId, task.callId)))
57
+ return { status: "settled" };
58
+ if (task.cancellationRequest) {
59
+ await task.cancellationRequest;
60
+ return { status: "already_requested" };
61
+ }
62
+ if (task.controller.signal.aborted)
63
+ return { status: "already_requested" };
64
+ const request = this.#requestCancellation(taskId, task, reason);
65
+ task.cancellationRequest = request;
66
+ try {
67
+ await request;
68
+ }
69
+ catch (error) {
70
+ task.cancellationRequest = undefined;
71
+ throw error;
72
+ }
73
+ return { status: "requested" };
74
+ }
75
+ async #requestCancellation(taskId, task, reason) {
76
+ if (task.recordedCancellationReason === undefined) {
77
+ await this.#record({ type: "task.cancellation_requested", taskId, callId: task.callId, reason });
78
+ task.recordedCancellationReason = reason;
79
+ }
80
+ // A failed flush leaves the successful append in place. Retry durability before
81
+ // signalling, preserving the original request rather than appending a second one.
82
+ await this.#flush();
83
+ task.controller.abort(task.recordedCancellationReason);
84
+ // Leave acceptance ordering intact. Activation or teardown records the pending task's terminal.
85
+ if (task.state === "pending")
86
+ this.#releasePending(task);
87
+ }
88
+ async cancelPending(taskId, callId, reason, deliverContext = false) {
36
89
  const task = this.#tasks.get(taskId);
37
90
  if (!task || task.callId !== callId) {
38
91
  await this.#settle({ type: "task.failed", taskId, callId, error: "activation_failed" }, false);
@@ -44,9 +97,16 @@ export class TaskSupervisor {
44
97
  task.detachSessionAbort();
45
98
  task.controller.abort(reason);
46
99
  this.#releasePending(task);
47
- await this.#settle({ type: "task.cancelled", taskId, callId, reason }, false);
100
+ await this.#settle({ type: "task.cancelled", taskId, callId, reason }, deliverContext);
48
101
  return true;
49
102
  }
103
+ /** A finished tool only transfers ownership of the task ID in its accepted outcome. */
104
+ async cancelUnaccepted(callId, acceptedTaskId) {
105
+ for (const [taskId, task] of this.#tasks) {
106
+ if (task.callId === callId && task.state === "pending" && taskId !== acceptedTaskId)
107
+ await this.cancelPending(taskId, callId, "tool_did_not_accept_task");
108
+ }
109
+ }
50
110
  async cancelAll(reason) {
51
111
  const pendingCancellations = [];
52
112
  const running = [];
@@ -67,14 +127,16 @@ export class TaskSupervisor {
67
127
  await Promise.allSettled([...pendingCancellations, ...running]);
68
128
  }
69
129
  #releasePending(task) {
130
+ const release = task.onPendingCancel;
131
+ task.onPendingCancel = undefined;
70
132
  try {
71
- task.onPendingCancel?.();
133
+ release?.();
72
134
  }
73
135
  catch (error) {
74
136
  this.#onError(error);
75
137
  }
76
138
  }
77
- async #register(callId, runner, options) {
139
+ async #register(callId, runner, explicitOutcome, options) {
78
140
  if (this.#sessionSignal.aborted)
79
141
  throw new Error("Session is closing");
80
142
  const taskId = crypto.randomUUID();
@@ -85,6 +147,9 @@ export class TaskSupervisor {
85
147
  callId,
86
148
  controller,
87
149
  runner,
150
+ explicitOutcome,
151
+ cancellationRequest: undefined,
152
+ recordedCancellationReason: undefined,
88
153
  onPendingCancel: options?.onPendingCancel,
89
154
  state: "pending",
90
155
  detachSessionAbort: () => this.#sessionSignal.removeEventListener("abort", abortFromSession),
@@ -109,7 +174,7 @@ export class TaskSupervisor {
109
174
  let terminal;
110
175
  let infrastructureFailure;
111
176
  try {
112
- const result = JsonValueSchema.parse(await task.runner({
177
+ const result = await task.runner({
113
178
  signal: task.controller.signal,
114
179
  report: async (update) => {
115
180
  try {
@@ -123,30 +188,45 @@ export class TaskSupervisor {
123
188
  throw error;
124
189
  }
125
190
  },
126
- }));
191
+ });
127
192
  if (infrastructureFailure)
128
193
  throw infrastructureFailure.error;
129
- if (task.controller.signal.aborted) {
194
+ if (task.explicitOutcome) {
195
+ const outcome = BackgroundTaskOutcomeSchema.parse(result);
196
+ const identity = { taskId, callId: task.callId };
197
+ terminal =
198
+ outcome.type === "completed"
199
+ ? { type: "task.completed", ...identity, result: outcome.result }
200
+ : outcome.type === "failed"
201
+ ? { type: "task.failed", ...identity, error: outcome.error }
202
+ : { type: "task.cancelled", ...identity, reason: outcome.reason };
203
+ }
204
+ else if (task.controller.signal.aborted) {
130
205
  terminal = {
131
206
  type: "task.cancelled",
132
207
  taskId,
133
208
  callId: task.callId,
134
- reason: String(task.controller.signal.reason ?? "cancelled"),
209
+ reason: cancellationReason(task.controller.signal),
135
210
  };
136
211
  }
137
212
  else {
138
- terminal = { type: "task.completed", taskId, callId: task.callId, result };
213
+ terminal = {
214
+ type: "task.completed",
215
+ taskId,
216
+ callId: task.callId,
217
+ result: JsonValueSchema.parse(result),
218
+ };
139
219
  }
140
220
  }
141
221
  catch (error) {
142
222
  if (infrastructureFailure)
143
223
  throw infrastructureFailure.error;
144
- if (task.controller.signal.aborted) {
224
+ if (!task.explicitOutcome && task.controller.signal.aborted) {
145
225
  terminal = {
146
226
  type: "task.cancelled",
147
227
  taskId,
148
228
  callId: task.callId,
149
- reason: String(task.controller.signal.reason ?? "cancelled"),
229
+ reason: cancellationReason(task.controller.signal),
150
230
  };
151
231
  }
152
232
  else {
@@ -164,7 +244,7 @@ export class TaskSupervisor {
164
244
  const key = taskKey(event.taskId, event.callId);
165
245
  if (this.#terminalTasks.has(key))
166
246
  return false;
167
- this.#terminalTasks.add(key);
247
+ this.#terminalTasks.set(key, event.taskId);
168
248
  try {
169
249
  await this.#record(event);
170
250
  }
@@ -196,6 +276,6 @@ export class TaskSupervisor {
196
276
  function taskKey(taskId, callId) {
197
277
  return JSON.stringify([taskId, callId]);
198
278
  }
199
- function errorMessage(error) {
200
- return error instanceof Error ? error.message : String(error);
279
+ function cancellationReason(signal) {
280
+ return String(signal.reason ?? "cancelled");
201
281
  }
@@ -0,0 +1,40 @@
1
+ import type { HarnessRecord, JsonValue, ProviderOutputSource, ToolPartState } from "../protocol/index.js";
2
+ export interface ToolDeliveryFailure {
3
+ eventId: string;
4
+ phase: "tool" | "progress" | "terminal";
5
+ error: string;
6
+ recordedAtMs: number;
7
+ }
8
+ /** One call's recorded execution, independent of delivery or spoken announcements. */
9
+ export interface ToolCallProjection {
10
+ callId: string;
11
+ name: string;
12
+ /** Native invocation provenance, independent of execution and delivery status. */
13
+ source?: ProviderOutputSource;
14
+ /** The same canonical state used by message tool parts. */
15
+ state: ToolPartState;
16
+ /** An accepted task is still pending even though its initial tool call settled. */
17
+ status: "pending" | "result_ready";
18
+ /** Retained after an accepted_task outcome upgrades to its eventual result. */
19
+ taskId?: string;
20
+ progress?: {
21
+ update: JsonValue;
22
+ recordedAtMs: number;
23
+ };
24
+ deliveryFailures: readonly ToolDeliveryFailure[];
25
+ }
26
+ /**
27
+ * Incremental projection of a single session's sequence-ordered event log. This
28
+ * observes execution; ToolRuntime and TaskSupervisor remain its only owners.
29
+ * Applying an event replaces the changed entry, leaving prior snapshots stable.
30
+ */
31
+ export declare class ToolCallTracker {
32
+ #private;
33
+ get(callId: string): ToolCallProjection | undefined;
34
+ list(): ToolCallProjection[];
35
+ hasPending(): boolean;
36
+ /** Returns the updated call, or undefined when the event has no effect. */
37
+ apply(event: HarnessRecord): ToolCallProjection | undefined;
38
+ }
39
+ /** Pure replay of the same lifecycle used by live harness consumers and messages. */
40
+ export declare function projectToolCalls(events: readonly HarnessRecord[]): ToolCallProjection[];