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,10 +1,8 @@
1
- import { toolDenialError } from "./tool-policy.js";
1
+ import { projectTranscriptFragments, } from "../protocol/index.js";
2
+ import { ToolCallTracker } from "./tool-calls.js";
2
3
  /**
3
- * Projects the flat, sequence-ordered event log into the v2 `Message[]` part model —
4
- * the shape every agent harness renders and persists (opencode / Vercel AI SDK v5 /
5
- * agent-server / echo). Pure AND deterministic over the recorded events: ids derive
6
- * from the opening event, so the same log always projects to the same identities —
7
- * re-folds diff cleanly, upserts stay idempotent, and renderers can key on part ids.
4
+ * Projects the sequence-ordered source log into display messages. Identities derive
5
+ * from opening events, so replay produces the same message and part IDs.
8
6
  *
9
7
  * Folding rules:
10
8
  * - `message.input` → a user message; its recorded `parts` project with full fidelity
@@ -12,27 +10,54 @@ import { toolDenialError } from "./tool-policy.js";
12
10
  * - `transcript.delta` from either speaker → a `streaming` text part accumulating deltas,
13
11
  * finalized in place by the matching `transcript` (streamId-correlated, so a
14
12
  * barge-in interrupted stream still finalizes into its original part).
15
- * - `audio.output` → an `audio` part on the current assistant message (deduped per
16
- * streamId), linked to its transcript text part by that streamId.
13
+ * - Response audio and its transcript share a stream's message owner, including
14
+ * when the first caption arrives after another speaker's input.
15
+ * - Confirmed interruption separates the next new assistant speech stream; late
16
+ * updates still belong to their original stream. Timed fragments keep their
17
+ * independent, revisable display grouping. Interrupted response streams retain
18
+ * their generated text with playback evidence; a final caption does not erase it.
19
+ * - `backend.output.*` → backend item-owned text/refusal/summary parts, separate
20
+ * from speech. Final items replace their content; failed responses settle only
21
+ * unfinished parts. Backend completion is never evidence of spoken delivery.
17
22
  * - `tool.requested` → ONE `tool` part in `running` state; `tool.completed`/`tool.failed`
18
23
  * settle it with the canonical outcome, and a task terminal upgrades an
19
24
  * `accepted_task` outcome to the task's real result. This is why a tool result
20
25
  * (e.g. a crisis card) replays from history: the outcome lives on the part.
21
26
  *
22
- * A tool part belongs to the assistant message open when it was requested; a new
23
- * assistant transcript or user turn opens the next message.
27
+ * A tool part retains an existing native item owner when one is known. Otherwise,
28
+ * continuous-output tools own a separate assistant message, independent of revisable
29
+ * captions; response-based tools belong to the assistant message open at invocation.
30
+ * Display messages are not provider turns or evidence of device playback.
24
31
  */
25
32
  export function projectMessages(events) {
33
+ return foldMessages(events).messages;
34
+ }
35
+ /** Internal shared fold: persistence and batch rendering use the same text evidence. */
36
+ export function foldMessages(events) {
26
37
  const started = events.find((event) => event.type === "session.started");
27
38
  const sessionId = started?.sessionId ?? events[0]?.sessionId ?? "unknown";
39
+ const fragmentGroups = new Map(projectTranscriptFragments(events.filter((event) => event.type === "transcript.fragment")).map((group) => [group.id, group]));
40
+ const ended = events.some((event) => event.type === "session.ended");
28
41
  const messages = [];
42
+ const textCompletion = {};
29
43
  let current;
44
+ let interruptedMessage;
30
45
  // callId → the tool part being advanced, so settlement updates the same part.
31
46
  const toolParts = new Map();
32
- // streamId → the assistant message + audio part, so repeated chunks fold into one.
47
+ const toolCalls = new ToolCallTracker();
48
+ // Source streams retain their owner even before the first text or audio part arrives.
49
+ const streamMessages = new Map();
50
+ // Content streams from one known native item keep its owner across interleaved ASR.
51
+ const itemMessages = new Map();
52
+ // streamId → audio part, so repeated chunks fold into one.
33
53
  const audioParts = new Map();
34
54
  // stream key → the streaming text part accumulating deltas, finalized by `transcript`.
35
55
  const textParts = new Map();
56
+ // Backend item/content ownership is independent of the current spoken display group.
57
+ const backendMessages = new Map();
58
+ const backendParts = new Map();
59
+ const backendItemStates = new Map();
60
+ const backendFailures = new Map();
36
61
  // Speakers can overlap even when a provider omits stream IDs.
37
62
  const streamlessTextKeys = new Map();
38
63
  // Identity is the opening event: message id = event id, part id = event id + ordinal.
@@ -51,11 +76,192 @@ export function projectMessages(events) {
51
76
  return current;
52
77
  return openMessage("assistant", event);
53
78
  };
79
+ const toolMessage = (event, source) => {
80
+ const owner = source?.itemId === undefined
81
+ ? undefined
82
+ : itemMessages.get(JSON.stringify(["assistant", source.scope, source.itemId]));
83
+ if (owner)
84
+ return owner;
85
+ if (started?.capabilities.outputLifecycle !== "continuous")
86
+ return assistantMessage(event);
87
+ const message = {
88
+ id: event.id,
89
+ sessionId,
90
+ role: "assistant",
91
+ createdAtMs: event.recordedAtMs,
92
+ parts: [],
93
+ };
94
+ // Tools have no native caption parent in continuous speech. Do not make this
95
+ // the current speech owner: a later caption must not join a tool-only message.
96
+ messages.push(message);
97
+ return message;
98
+ };
99
+ const speechMessage = (role, event, streamId, source) => {
100
+ const key = streamId === undefined ? undefined : `${role}:${streamId}`;
101
+ const itemKey = source?.itemId === undefined ? undefined : JSON.stringify([role, source.scope, source.itemId]);
102
+ const owner = (key === undefined ? undefined : streamMessages.get(key)) ??
103
+ (itemKey === undefined ? undefined : itemMessages.get(itemKey));
104
+ const resumed = key !== undefined && interruptedMessage !== undefined && interruptedMessage === current;
105
+ const message = owner ?? (role === "user" || resumed ? openMessage(role, event) : assistantMessage(event));
106
+ if (key !== undefined) {
107
+ streamMessages.set(key, message);
108
+ if (role === "assistant" && owner === undefined)
109
+ interruptedMessage = undefined;
110
+ }
111
+ if (itemKey !== undefined)
112
+ itemMessages.set(itemKey, message);
113
+ return message;
114
+ };
115
+ const backendItemKey = (source) => JSON.stringify([source.responseId, source.itemId]);
116
+ const backendPart = (event, source, kind, ordinal = 0) => {
117
+ const itemKey = backendItemKey(source);
118
+ let message = backendMessages.get(itemKey);
119
+ if (!message) {
120
+ message = { id: event.id, sessionId, role: "assistant", createdAtMs: event.recordedAtMs, parts: [] };
121
+ backendMessages.set(itemKey, message);
122
+ // Do not change `current`: backend activity cannot open or split a spoken caption.
123
+ messages.push(message);
124
+ }
125
+ else if (message.parts.length === 0 && !messages.includes(message)) {
126
+ messages.push(message);
127
+ }
128
+ const key = JSON.stringify([
129
+ itemKey,
130
+ kind === "reasoning_summary" ? "summary" : "content",
131
+ kind === "reasoning_summary" ? source.summaryIndex : source.contentIndex,
132
+ ]);
133
+ let part = backendParts.get(key);
134
+ if (!part) {
135
+ part =
136
+ kind === "reasoning_summary"
137
+ ? {
138
+ ...partBase(message, event, ordinal),
139
+ type: "reasoning",
140
+ summary: true,
141
+ text: "",
142
+ state: "streaming",
143
+ source: { ...source },
144
+ }
145
+ : {
146
+ ...partBase(message, event, ordinal),
147
+ type: "text",
148
+ ...(kind === "refusal" ? { refusal: true } : {}),
149
+ text: "",
150
+ state: "streaming",
151
+ source: { ...source },
152
+ };
153
+ backendParts.set(key, part);
154
+ message.parts.push(part);
155
+ message.parts.sort((a, b) => (a.source?.contentIndex ?? a.source?.summaryIndex ?? 0) -
156
+ (b.source?.contentIndex ?? b.source?.summaryIndex ?? 0));
157
+ }
158
+ part.source = { ...part.source, ...source };
159
+ const failure = source.responseId === undefined ? undefined : backendFailures.get(source.responseId);
160
+ if (failure && !backendItemStates.has(itemKey))
161
+ part.state = failure;
162
+ if (part.type === "text")
163
+ textCompletion[part.id] ??= "unconfirmed";
164
+ return part;
165
+ };
54
166
  for (const event of events) {
167
+ const toolCall = toolCalls.apply(event);
168
+ if (toolCall) {
169
+ const part = toolParts.get(toolCall.callId);
170
+ if (part)
171
+ part.state = toolCall.state;
172
+ }
55
173
  switch (event.type) {
174
+ case "backend.output.delta": {
175
+ const key = backendItemKey(event.source);
176
+ // No delta can extend an item after its authoritative final snapshot.
177
+ if (backendItemStates.has(key))
178
+ break;
179
+ const part = backendPart(event, event.source, event.kind);
180
+ part.text += event.delta;
181
+ break;
182
+ }
183
+ case "backend.output": {
184
+ const key = backendItemKey(event.source);
185
+ backendItemStates.set(key, event.status);
186
+ const parts = event.content.map((content, ordinal) => {
187
+ const source = {
188
+ ...event.source,
189
+ ...("contentIndex" in content
190
+ ? { contentIndex: content.contentIndex }
191
+ : { summaryIndex: content.summaryIndex }),
192
+ };
193
+ const part = backendPart(event, source, content.kind, ordinal);
194
+ part.text = content.text;
195
+ part.state =
196
+ event.status === "incomplete" || event.status === "cancelled" || event.status === "failed"
197
+ ? event.status
198
+ : event.status === "completed"
199
+ ? "done"
200
+ : (backendFailures.get(event.source.responseId) ?? "done");
201
+ if (part.type === "text") {
202
+ if (content.kind === "refusal")
203
+ part.refusal = true;
204
+ else
205
+ delete part.refusal;
206
+ // Text has ended, but a status-less snapshot is not proof of item success.
207
+ textCompletion[part.id] = event.status === "completed" ? "provider-final" : "unconfirmed";
208
+ }
209
+ return part;
210
+ });
211
+ const message = backendMessages.get(key);
212
+ if (message) {
213
+ const retained = new Set(parts.map((part) => part.id));
214
+ for (const part of message.parts)
215
+ if (!retained.has(part.id))
216
+ delete textCompletion[part.id];
217
+ message.parts = parts;
218
+ const index = messages.indexOf(message);
219
+ if (parts.length === 0 && index >= 0)
220
+ messages.splice(index, 1);
221
+ }
222
+ break;
223
+ }
224
+ case "backend.response": {
225
+ if (event.phase !== "completed" ||
226
+ (event.status !== "incomplete" && event.status !== "cancelled" && event.status !== "failed"))
227
+ break;
228
+ backendFailures.set(event.responseId, event.status);
229
+ for (const part of backendParts.values()) {
230
+ if (part.source?.responseId !== event.responseId)
231
+ continue;
232
+ const state = backendItemStates.get(backendItemKey(part.source));
233
+ if (state !== undefined && state !== "in_progress")
234
+ continue;
235
+ part.state = event.status;
236
+ }
237
+ break;
238
+ }
239
+ case "transcript.fragment": {
240
+ const group = fragmentGroups.get(event.fragmentId);
241
+ if (!group)
242
+ break;
243
+ fragmentGroups.delete(event.fragmentId);
244
+ const message = openMessage(group.role, event);
245
+ textCompletion[`${event.id}/p0`] = "unconfirmed";
246
+ message.parts.push({
247
+ ...partBase(message, event),
248
+ type: "text",
249
+ text: group.text,
250
+ state: ended ? "done" : "streaming",
251
+ transcript: {
252
+ fragmentIds: group.fragmentIds,
253
+ startMs: group.startMs,
254
+ endMs: group.endMs,
255
+ timebase: group.timebase,
256
+ },
257
+ });
258
+ break;
259
+ }
56
260
  case "message.input": {
57
261
  const message = openMessage("user", event);
58
262
  for (const [ordinal, part] of (event.parts ?? [{ type: "text", text: event.text }]).entries()) {
263
+ if (part.type === "text")
264
+ textCompletion[`${event.id}/p${ordinal}`] = "input";
59
265
  message.parts.push(part.type === "text"
60
266
  ? { ...partBase(message, event, ordinal), type: "text", text: part.text, state: "done" }
61
267
  : {
@@ -76,9 +282,14 @@ export function projectMessages(events) {
76
282
  if (existing) {
77
283
  if (existing.state === "streaming")
78
284
  existing.text += event.delta;
285
+ if (event.source !== undefined) {
286
+ existing.source = { ...existing.source, ...event.source };
287
+ if (event.streamId !== undefined)
288
+ speechMessage(event.role, event, event.streamId, existing.source);
289
+ }
79
290
  break;
80
291
  }
81
- const message = event.role === "assistant" ? assistantMessage(event) : openMessage("user", event);
292
+ const message = speechMessage(event.role, event, event.streamId, event.source);
82
293
  const key = `${event.role}:${event.streamId ?? message.id}`;
83
294
  if (event.streamId === undefined)
84
295
  streamlessTextKeys.set(event.role, key);
@@ -87,7 +298,10 @@ export function projectMessages(events) {
87
298
  type: "text",
88
299
  text: event.delta,
89
300
  state: "streaming",
301
+ ...(event.streamId === undefined ? {} : { streamId: event.streamId }),
302
+ ...(event.source === undefined ? {} : { source: { ...event.source } }),
90
303
  };
304
+ textCompletion[part.id] = "unconfirmed";
91
305
  textParts.set(key, part);
92
306
  message.parts.push(part);
93
307
  break;
@@ -102,33 +316,47 @@ export function projectMessages(events) {
102
316
  if (key !== undefined && streaming !== undefined) {
103
317
  streaming.text = event.text;
104
318
  streaming.state = "done";
319
+ if (event.source !== undefined) {
320
+ streaming.source = { ...streaming.source, ...event.source };
321
+ if (event.streamId !== undefined)
322
+ speechMessage(event.role, event, event.streamId, streaming.source);
323
+ }
324
+ textCompletion[streaming.id] = "provider-final";
105
325
  if (event.streamId === undefined) {
106
326
  textParts.delete(key);
107
327
  streamlessTextKeys.delete(event.role);
108
328
  }
109
329
  break;
110
330
  }
111
- const message = event.role === "assistant" ? assistantMessage(event) : openMessage("user", event);
331
+ const message = speechMessage(event.role, event, event.streamId, event.source);
112
332
  const part = {
113
333
  ...partBase(message, event),
114
334
  type: "text",
115
335
  text: event.text,
116
336
  state: "done",
337
+ ...(event.streamId === undefined ? {} : { streamId: event.streamId }),
338
+ ...(event.source === undefined ? {} : { source: { ...event.source } }),
117
339
  };
340
+ textCompletion[part.id] = "provider-final";
118
341
  if (event.streamId !== undefined)
119
342
  textParts.set(`${event.role}:${event.streamId}`, part);
120
343
  message.parts.push(part);
121
344
  break;
122
345
  }
123
346
  case "audio.output": {
347
+ // A continuous stream spans many display groups. Keep it on the session
348
+ // timeline rather than attaching the whole recording to the first message.
349
+ if (started?.capabilities.outputLifecycle === "continuous")
350
+ break;
124
351
  const streamId = event.chunk.streamId;
125
352
  if (audioParts.has(streamId))
126
353
  break;
127
- const message = assistantMessage(event);
354
+ const message = speechMessage("assistant", event, streamId, event.source);
128
355
  const part = {
129
356
  ...partBase(message, event),
130
357
  type: "audio",
131
358
  streamId,
359
+ ...(event.source === undefined ? {} : { source: { ...event.source } }),
132
360
  sampleRate: event.chunk.sampleRate,
133
361
  state: "done",
134
362
  };
@@ -136,83 +364,49 @@ export function projectMessages(events) {
136
364
  message.parts.push(part);
137
365
  break;
138
366
  }
367
+ case "audio.interrupted": {
368
+ const owner = streamMessages.get(`assistant:${event.streamId}`);
369
+ if (owner !== undefined && owner === current)
370
+ interruptedMessage = owner;
371
+ break;
372
+ }
139
373
  case "tool.requested": {
140
- if (toolParts.has(event.call.callId))
374
+ if (!toolCall)
141
375
  break;
142
- const message = assistantMessage(event);
376
+ const message = toolMessage(event, toolCall.source);
143
377
  const part = {
144
378
  ...partBase(message, event),
145
379
  type: "tool",
146
380
  toolCallId: event.call.callId,
147
381
  toolName: event.call.name,
148
- state: { status: "running", input: event.call.arguments, startedAtMs: event.recordedAtMs },
382
+ ...(toolCall.source === undefined ? {} : { source: { ...toolCall.source } }),
383
+ state: toolCall.state,
149
384
  };
150
385
  toolParts.set(event.call.callId, part);
151
386
  message.parts.push(part);
152
387
  break;
153
388
  }
154
- case "tool.completed": {
155
- const part = toolParts.get(event.callId);
156
- if (part?.state.status !== "running")
157
- break;
158
- part.state = {
159
- status: "settled",
160
- input: part.state.input,
161
- outcome: event.outcome,
162
- startedAtMs: part.state.startedAtMs,
163
- endedAtMs: event.recordedAtMs,
164
- };
165
- break;
166
- }
167
- case "tool.failed": {
168
- const part = toolParts.get(event.callId);
169
- if (part?.state.status !== "running")
170
- break;
171
- part.state = {
172
- status: "settled",
173
- input: part.state.input,
174
- outcome: { type: "failed", error: event.error },
175
- startedAtMs: part.state.startedAtMs,
176
- endedAtMs: event.recordedAtMs,
177
- };
178
- break;
179
- }
180
- case "tool.denied": {
181
- // Admission refusal settles the part like a failure; the log keeps the distinction.
182
- const part = toolParts.get(event.callId);
183
- if (part?.state.status !== "running")
184
- break;
185
- part.state = {
186
- status: "settled",
187
- input: part.state.input,
188
- outcome: { type: "failed", error: toolDenialError(event.reason) },
189
- startedAtMs: part.state.startedAtMs,
190
- endedAtMs: event.recordedAtMs,
191
- };
192
- break;
193
- }
194
- case "task.completed":
195
- case "task.failed":
196
- case "task.cancelled": {
197
- // A background task settling upgrades its tool part's accepted_task handoff to
198
- // the real result, so task-shaped tools replay from history like direct ones.
199
- const part = toolParts.get(event.callId);
200
- if (part?.state.status !== "settled" || part.state.outcome.type !== "accepted_task")
201
- break;
202
- part.state = {
203
- ...part.state,
204
- outcome: event.type === "task.completed"
205
- ? { type: "completed", value: event.result }
206
- : event.type === "task.failed"
207
- ? { type: "failed", error: event.error }
208
- : { type: "failed", error: `Task cancelled: ${event.reason}` },
209
- endedAtMs: event.recordedAtMs,
210
- };
211
- break;
212
- }
213
389
  default:
214
390
  break;
215
391
  }
216
392
  }
217
- return messages;
393
+ // Generation may finish before playback is interrupted, or its final caption may
394
+ // arrive afterward. Join by response stream after folding so both orders retain
395
+ // the same evidence without changing original text or guessing a heard prefix.
396
+ // A continuous session stream cannot identify an individual Live caption group.
397
+ if (started?.capabilities.outputLifecycle !== "continuous") {
398
+ const interrupted = new Set(events.filter((event) => event.type === "audio.interrupted").map((event) => event.streamId));
399
+ for (const message of messages) {
400
+ if (message.role !== "assistant")
401
+ continue;
402
+ for (const part of message.parts) {
403
+ if ((part.type === "text" || part.type === "audio") &&
404
+ part.source?.scope !== "backend" &&
405
+ part.streamId !== undefined &&
406
+ interrupted.has(part.streamId))
407
+ part.playback = "interrupted";
408
+ }
409
+ }
410
+ }
411
+ return { messages, textCompletion };
218
412
  }
@@ -33,6 +33,9 @@ export interface SessionReportDiff {
33
33
  transcriptLines: MetricDelta;
34
34
  errorCount: MetricDelta;
35
35
  usage: Record<keyof UsageTotals, MetricDelta>;
36
+ /** Continuous voice duration and backend tokens are separate accounting units. */
37
+ voiceDurationSeconds?: MetricDelta;
38
+ backendUsage?: Record<keyof UsageTotals, MetricDelta>;
36
39
  /** Union of tool names from both reports, sorted, one row each. */
37
40
  toolCalls: ToolCallDiff[];
38
41
  }
@@ -11,6 +11,12 @@ export function diffSessionReports(baseline, candidate) {
11
11
  usage[key] = metric(baseline.usage[key], candidate.usage[key]);
12
12
  }
13
13
  const names = [...new Set([...baseline.toolCalls, ...candidate.toolCalls].map((call) => call.name))].sort();
14
+ const backendUsage = {};
15
+ if (baseline.backendUsage || candidate.backendUsage) {
16
+ for (const key of Object.keys(usage)) {
17
+ backendUsage[key] = metric(baseline.backendUsage?.[key], candidate.backendUsage?.[key]);
18
+ }
19
+ }
14
20
  return {
15
21
  baseline: identity(baseline),
16
22
  candidate: identity(candidate),
@@ -22,6 +28,10 @@ export function diffSessionReports(baseline, candidate) {
22
28
  transcriptLines: metric(baseline.transcripts.length, candidate.transcripts.length),
23
29
  errorCount: metric(baseline.errors.length, candidate.errors.length),
24
30
  usage,
31
+ ...(baseline.voiceDuration || candidate.voiceDuration
32
+ ? { voiceDurationSeconds: metric(baseline.voiceDuration?.seconds, candidate.voiceDuration?.seconds) }
33
+ : {}),
34
+ ...(baseline.backendUsage || candidate.backendUsage ? { backendUsage } : {}),
25
35
  toolCalls: names.map((name) => toolDiff(name, baseline.toolCalls, candidate.toolCalls)),
26
36
  };
27
37
  }
@@ -1,4 +1,6 @@
1
- import { type HarnessEvent, type ToolOutcome } from "../protocol/index.js";
1
+ import { type HarnessRecord, type ToolOutcome } from "../protocol/index.js";
2
+ import { type UsageTotals } from "./usage-tracker.js";
3
+ export type { UsageTotals } from "./usage-tracker.js";
2
4
  export interface ToolCallReport {
3
5
  callId: string;
4
6
  name: string;
@@ -13,17 +15,11 @@ export interface TranscriptLine {
13
15
  role: "user" | "assistant";
14
16
  text: string;
15
17
  atMs: number;
16
- }
17
- /** Session token totals summed over provider usage reports; zeros when none arrived. */
18
- export interface UsageTotals {
19
- responses: number;
20
- inputTokens: number;
21
- outputTokens: number;
22
- totalTokens: number;
23
- cachedInputTokens: number;
24
- audioInputTokens: number;
25
- audioOutputTokens: number;
26
- cachedAudioInputTokens: number;
18
+ nativeTiming?: {
19
+ startMs: number;
20
+ endMs: number;
21
+ timebase: "provider-session";
22
+ };
27
23
  }
28
24
  export interface SessionReport {
29
25
  sessionId: string;
@@ -33,7 +29,7 @@ export interface SessionReport {
33
29
  readyAtMs?: number;
34
30
  firstInputAtMs?: number;
35
31
  firstOutputAtMs?: number;
36
- /** First model audio relative to first microphone audio. */
32
+ /** First model audio relative to first input audio; omitted for continuous output (which includes silence). */
37
33
  responseLatencyMs?: number;
38
34
  inputAudioMs: number;
39
35
  outputAudioMs: number;
@@ -45,9 +41,16 @@ export interface SessionReport {
45
41
  recoverable: boolean;
46
42
  }[];
47
43
  usage: UsageTotals;
44
+ /** Separate accounting domains: duration snapshots never become token deltas. */
45
+ voiceDuration?: {
46
+ seconds: number;
47
+ final: boolean;
48
+ };
49
+ backendUsage?: UsageTotals;
50
+ finalized?: boolean;
48
51
  }
49
52
  /**
50
53
  * Derives latency and outcome metrics from a session log. Pure over the recorded events,
51
54
  * so it works identically on live sessions, replays, and shadow runs.
52
55
  */
53
- export declare function summarizeSession(events: readonly HarnessEvent[]): SessionReport;
56
+ export declare function summarizeSession(events: readonly HarnessRecord[]): SessionReport;
@@ -1,5 +1,6 @@
1
- import { audioSampleCount } from "../protocol/index.js";
1
+ import { audioSampleCount, projectTranscriptFragments, } from "../protocol/index.js";
2
2
  import { toolDenialError } from "./tool-policy.js";
3
+ import { SessionUsageTracker } from "./usage-tracker.js";
3
4
  /**
4
5
  * Derives latency and outcome metrics from a session log. Pure over the recorded events,
5
6
  * so it works identically on live sessions, replays, and shadow runs.
@@ -16,33 +17,16 @@ export function summarizeSession(events) {
16
17
  let outputAudioMs = 0;
17
18
  const toolCalls = new Map();
18
19
  const transcripts = [];
20
+ const transcriptStreams = new Map();
19
21
  const taskIds = new Set();
20
22
  const errors = [];
21
- const usage = {
22
- responses: 0,
23
- inputTokens: 0,
24
- outputTokens: 0,
25
- totalTokens: 0,
26
- cachedInputTokens: 0,
27
- audioInputTokens: 0,
28
- audioOutputTokens: 0,
29
- cachedAudioInputTokens: 0,
30
- };
23
+ const accounting = new SessionUsageTracker({ sessionId: started.sessionId });
31
24
  for (const event of events) {
25
+ accounting.apply(event);
32
26
  switch (event.type) {
33
27
  case "provider.ready":
34
28
  readyAtMs ??= event.sessionTimeMs;
35
29
  break;
36
- case "usage":
37
- usage.responses += 1;
38
- usage.inputTokens += event.inputTokens;
39
- usage.outputTokens += event.outputTokens;
40
- usage.totalTokens += event.totalTokens;
41
- usage.cachedInputTokens += event.cachedInputTokens ?? 0;
42
- usage.audioInputTokens += event.audioInputTokens ?? 0;
43
- usage.audioOutputTokens += event.audioOutputTokens ?? 0;
44
- usage.cachedAudioInputTokens += event.cachedAudioInputTokens ?? 0;
45
- break;
46
30
  case "audio.input":
47
31
  firstInputAtMs ??= event.sessionTimeMs;
48
32
  inputAudioMs += (audioSampleCount(event.chunk) / event.chunk.sampleRate) * 1000;
@@ -51,9 +35,21 @@ export function summarizeSession(events) {
51
35
  firstOutputAtMs ??= event.sessionTimeMs;
52
36
  outputAudioMs += (audioSampleCount(event.chunk) / event.chunk.sampleRate) * 1000;
53
37
  break;
54
- case "transcript":
55
- transcripts.push({ role: event.role, text: event.text, atMs: event.sessionTimeMs });
38
+ case "transcript": {
39
+ const key = event.streamId === undefined ? undefined : JSON.stringify([event.role, event.streamId]);
40
+ const previous = key === undefined ? undefined : transcriptStreams.get(key);
41
+ if (previous) {
42
+ // Revised finals replace the same utterance, preserving its original order and timing.
43
+ previous.text = event.text;
44
+ }
45
+ else {
46
+ const line = { role: event.role, text: event.text, atMs: event.sessionTimeMs };
47
+ transcripts.push(line);
48
+ if (key !== undefined)
49
+ transcriptStreams.set(key, line);
50
+ }
56
51
  break;
52
+ }
57
53
  case "message.input":
58
54
  transcripts.push({ role: "user", text: event.text, atMs: event.sessionTimeMs });
59
55
  break;
@@ -103,6 +99,22 @@ export function summarizeSession(events) {
103
99
  break;
104
100
  }
105
101
  }
102
+ const fragments = events.filter((event) => event.type === "transcript.fragment");
103
+ const fragmentEvents = new Map(fragments.map((event) => [event.fragmentId, event]));
104
+ for (const group of projectTranscriptFragments(fragments)) {
105
+ const opening = fragmentEvents.get(group.id);
106
+ if (!opening)
107
+ continue;
108
+ transcripts.push({
109
+ role: group.role,
110
+ text: group.text,
111
+ atMs: opening.sessionTimeMs,
112
+ nativeTiming: { startMs: group.startMs, endMs: group.endMs, timebase: group.timebase },
113
+ });
114
+ }
115
+ if (fragments.length)
116
+ transcripts.sort((a, b) => a.atMs - b.atMs);
117
+ const { usage, voiceDuration, backendUsage, finalized } = accounting.snapshot();
106
118
  return {
107
119
  sessionId: started.sessionId,
108
120
  providerId: started.providerId,
@@ -114,11 +126,16 @@ export function summarizeSession(events) {
114
126
  taskIds: [...taskIds],
115
127
  usage,
116
128
  errors,
129
+ ...(voiceDuration === undefined ? {} : { voiceDuration }),
130
+ ...(backendUsage === undefined ? {} : { backendUsage }),
131
+ ...(finalized === undefined ? {} : { finalized }),
117
132
  ...(endedReason === undefined ? {} : { endedReason }),
118
133
  ...(readyAtMs === undefined ? {} : { readyAtMs }),
119
134
  ...(firstInputAtMs === undefined ? {} : { firstInputAtMs }),
120
135
  ...(firstOutputAtMs === undefined ? {} : { firstOutputAtMs }),
121
- ...(firstInputAtMs === undefined || firstOutputAtMs === undefined
136
+ ...(started.capabilities.outputLifecycle === "continuous" ||
137
+ firstInputAtMs === undefined ||
138
+ firstOutputAtMs === undefined
122
139
  ? {}
123
140
  : { responseLatencyMs: firstOutputAtMs - firstInputAtMs }),
124
141
  };