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.
- package/README.md +92 -5
- package/dist/browser/worklets/capture-processor.js +34 -0
- package/dist/browser/worklets/playback-processor.js +188 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/internal/browser/browser-voice-client.d.ts +33 -0
- package/dist/internal/browser/browser-voice-client.js +316 -0
- package/dist/internal/browser/index.d.ts +2 -0
- package/dist/internal/browser/index.js +1 -0
- package/dist/internal/harness/conversation-context.d.ts +45 -0
- package/dist/internal/harness/conversation-context.js +79 -0
- package/dist/internal/harness/conversation-projection.d.ts +55 -0
- package/dist/internal/harness/conversation-projection.js +145 -0
- package/dist/internal/harness/event-stream.d.ts +23 -2
- package/dist/internal/harness/event-stream.js +148 -18
- package/dist/internal/harness/index.d.ts +7 -1
- package/dist/internal/harness/index.js +7 -1
- package/dist/internal/harness/message.d.ts +24 -11
- package/dist/internal/harness/message.js +271 -77
- package/dist/internal/harness/report-diff.d.ts +3 -0
- package/dist/internal/harness/report-diff.js +10 -0
- package/dist/internal/harness/report.d.ts +17 -14
- package/dist/internal/harness/report.js +41 -24
- package/dist/internal/harness/runtime-limits.d.ts +18 -0
- package/dist/internal/harness/runtime-limits.js +19 -0
- package/dist/internal/harness/session-persistence.d.ts +16 -0
- package/dist/internal/harness/session-persistence.js +106 -0
- package/dist/internal/harness/shadow.d.ts +5 -5
- package/dist/internal/harness/shadow.js +214 -52
- package/dist/internal/harness/socket-bridge.d.ts +45 -4
- package/dist/internal/harness/socket-bridge.js +215 -46
- package/dist/internal/harness/task-supervisor.d.ts +6 -2
- package/dist/internal/harness/task-supervisor.js +97 -15
- package/dist/internal/harness/tool-calls.d.ts +40 -0
- package/dist/internal/harness/tool-calls.js +132 -0
- package/dist/internal/harness/tool-policy.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.js +37 -40
- package/dist/internal/harness/tools.d.ts +21 -0
- package/dist/internal/harness/tools.js +7 -1
- package/dist/internal/harness/usage-tracker.d.ts +39 -0
- package/dist/internal/harness/usage-tracker.js +104 -0
- package/dist/internal/harness/voice-session.d.ts +39 -8
- package/dist/internal/harness/voice-session.js +353 -58
- package/dist/internal/harness/xo.d.ts +7 -12
- package/dist/internal/harness/xo.js +25 -13
- package/dist/internal/protocol/async-queue.d.ts +22 -1
- package/dist/internal/protocol/async-queue.js +86 -12
- package/dist/internal/protocol/audio.d.ts +16 -2
- package/dist/internal/protocol/audio.js +23 -5
- package/dist/internal/protocol/backend-output.d.ts +70 -0
- package/dist/internal/protocol/backend-output.js +39 -0
- package/dist/internal/protocol/event-json.d.ts +3 -0
- package/dist/internal/protocol/event-json.js +15 -0
- package/dist/internal/protocol/events.d.ts +342 -4
- package/dist/internal/protocol/events.js +53 -25
- package/dist/internal/protocol/index.d.ts +5 -0
- package/dist/internal/protocol/index.js +5 -0
- package/dist/internal/protocol/output-source.d.ts +18 -0
- package/dist/internal/protocol/output-source.js +15 -0
- package/dist/internal/protocol/paced-audio.d.ts +27 -0
- package/dist/internal/protocol/paced-audio.js +117 -0
- package/dist/internal/protocol/parts.d.ts +154 -0
- package/dist/internal/protocol/parts.js +32 -6
- package/dist/internal/protocol/provider.d.ts +308 -1
- package/dist/internal/protocol/provider.js +102 -3
- package/dist/internal/protocol/records.d.ts +773 -0
- package/dist/internal/protocol/records.js +46 -0
- package/dist/internal/protocol/tools.d.ts +40 -0
- package/dist/internal/protocol/tools.js +12 -0
- package/dist/internal/protocol/transcript.d.ts +19 -0
- package/dist/internal/protocol/transcript.js +53 -0
- package/dist/internal/provider/contract.d.ts +25 -2
- package/dist/internal/provider/event-queue.d.ts +17 -0
- package/dist/internal/provider/event-queue.js +78 -0
- package/dist/internal/provider/grok-voice.d.ts +12 -9
- package/dist/internal/provider/grok-voice.js +26 -15
- package/dist/internal/provider/index.d.ts +1 -0
- package/dist/internal/provider/index.js +1 -0
- package/dist/internal/provider/live-session.d.ts +29 -0
- package/dist/internal/provider/live-session.js +840 -0
- package/dist/internal/provider/node-socket.js +6 -0
- package/dist/internal/provider/openai-live.d.ts +62 -0
- package/dist/internal/provider/openai-live.js +160 -0
- package/dist/internal/provider/openai-realtime.d.ts +9 -5
- package/dist/internal/provider/openai-realtime.js +24 -10
- package/dist/internal/provider/realtime-session.d.ts +12 -1
- package/dist/internal/provider/realtime-session.js +373 -70
- package/dist/internal/provider/realtime-socket.d.ts +3 -1
- package/dist/internal/provider/tool-status-context.d.ts +10 -0
- package/dist/internal/provider/tool-status-context.js +30 -0
- package/dist/internal/provider/workers-socket.js +63 -15
- package/dist/internal/provider/workers.d.ts +1 -0
- package/dist/internal/provider/workers.js +1 -0
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
- package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
- package/dist/internal/storage/event-store.d.ts +22 -4
- package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
- package/dist/internal/storage/jsonl-event-store.js +21 -16
- package/dist/internal/storage/memory-event-store.d.ts +4 -3
- package/dist/internal/storage/memory-event-store.js +8 -2
- package/dist/internal/storage/memory.d.ts +1 -1
- package/dist/internal/testkit/events.d.ts +14 -0
- package/dist/internal/testkit/events.js +33 -0
- package/dist/internal/testkit/runtime.d.ts +3 -0
- package/dist/internal/testkit/runtime.js +3 -0
- package/dist/internal/testkit/trajectory.d.ts +23 -0
- package/dist/internal/testkit/trajectory.js +58 -0
- package/dist/internal/tools-openai/index.d.ts +2 -0
- package/dist/internal/tools-openai/index.js +79 -72
- package/dist/internal/tools-openai/responses.d.ts +5 -1
- package/dist/internal/tools-openai/responses.js +53 -5
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +7 -1
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { projectTranscriptFragments, } from "../protocol/index.js";
|
|
2
|
+
import { ToolCallTracker } from "./tool-calls.js";
|
|
2
3
|
/**
|
|
3
|
-
* Projects the
|
|
4
|
-
*
|
|
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
|
-
* -
|
|
16
|
-
*
|
|
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
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
374
|
+
if (!toolCall)
|
|
141
375
|
break;
|
|
142
|
-
const message =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
136
|
+
...(started.capabilities.outputLifecycle === "continuous" ||
|
|
137
|
+
firstInputAtMs === undefined ||
|
|
138
|
+
firstOutputAtMs === undefined
|
|
122
139
|
? {}
|
|
123
140
|
: { responseLatencyMs: firstOutputAtMs - firstInputAtMs }),
|
|
124
141
|
};
|