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