@vellumai/assistant 0.10.10-dev.202607171631.a0a52fd → 0.10.10-dev.202607171828.fc2f5d5
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/openapi.yaml +0 -7
- package/package.json +1 -1
- package/src/config/feature-flag-registry.json +1 -18
- package/src/daemon/conversation-agent-loop.ts +1 -45
- package/src/daemon/conversation-process.ts +0 -22
- package/src/daemon/conversation.ts +0 -2
- package/src/hooks/types.ts +0 -9
- package/src/live-voice/__tests__/live-voice-vad.test.ts +396 -10
- package/src/live-voice/live-voice-session.ts +106 -20
- package/src/persistence/conversation-crud.ts +0 -24
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +16 -5
- package/src/runtime/routes/conversation-routes.ts +1 -24
- package/src/runtime/routes/identity-routes.ts +23 -2
- package/src/telemetry/turn-events-store.ts +0 -20
package/openapi.yaml
CHANGED
|
@@ -19152,13 +19152,6 @@ paths:
|
|
|
19152
19152
|
snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send
|
|
19153
19153
|
path only — slash-command content bypasses it."
|
|
19154
19154
|
type: boolean
|
|
19155
|
-
source:
|
|
19156
|
-
description:
|
|
19157
|
-
'How the send was initiated when the message was sent on the user''s behalf by the UI rather than typed by
|
|
19158
|
-
hand. Current value: "nav_redirect" (a navigation shortcut redirected the user into chat with a
|
|
19159
|
-
pre-filled message). Persisted onto the message metadata for analytics attribution and used to steer
|
|
19160
|
-
the assistant toward a brief, concrete reply. Omit for messages the user typed.'
|
|
19161
|
-
type: string
|
|
19162
19155
|
onboarding:
|
|
19163
19156
|
type: object
|
|
19164
19157
|
properties:
|
package/package.json
CHANGED
|
@@ -16,11 +16,7 @@
|
|
|
16
16
|
"label": "Activation Flow Experiment 2026-06-03",
|
|
17
17
|
"description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail; personal-page = new sign-up-page variant (front-end only). Targeted via LaunchDarkly.",
|
|
18
18
|
"defaultEnabled": "control",
|
|
19
|
-
"values": [
|
|
20
|
-
"control",
|
|
21
|
-
"variant-a",
|
|
22
|
-
"personal-page"
|
|
23
|
-
]
|
|
19
|
+
"values": ["control", "variant-a", "personal-page"]
|
|
24
20
|
},
|
|
25
21
|
{
|
|
26
22
|
"id": "local-docker-enabled",
|
|
@@ -373,19 +369,6 @@
|
|
|
373
369
|
"label": "Chat Credential Reveal",
|
|
374
370
|
"description": "Persist chat secret redactions as sentinel markers instead of the legacy HTML marker, enriched with the vault identity when the value byte-matches a credential revealed this turn. Also gates the `credentials reveal --for-chat` channel and the live-stream swap that replaces an echoed reveal plaintext with its sentinel before emission. Redaction itself is NOT gated: a route-proven reveal plaintext stays out of persisted rows in both modes. Client chip rendering is gated on daemon version, not on this flag.",
|
|
375
371
|
"defaultEnabled": false
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
"id": "sidenav-gating-experiment-2026-07-16",
|
|
379
|
-
"scope": "client",
|
|
380
|
-
"key": "sidenav-gating-experiment-2026-07-16",
|
|
381
|
-
"label": "Sidenav Gating Experiment 2026-07-16",
|
|
382
|
-
"description": "First-session sidenav gating experiment (activation). none = not in cohort (default; no gating, no experiment events); control = cohort control (current experience, experiment events emit); gated = sidenav items disabled until 1/5 sent messages with avatar-bubble redirects. Cohort (new signups, 50/50 control-vs-gated) targeted via LaunchDarkly.",
|
|
383
|
-
"defaultEnabled": "none",
|
|
384
|
-
"values": [
|
|
385
|
-
"none",
|
|
386
|
-
"control",
|
|
387
|
-
"gated"
|
|
388
|
-
]
|
|
389
372
|
}
|
|
390
373
|
]
|
|
391
374
|
}
|
|
@@ -254,16 +254,6 @@ async function withAbortWatchdog<T>(
|
|
|
254
254
|
|
|
255
255
|
// ── runAgentLoop ─────────────────────────────────────────────────────
|
|
256
256
|
|
|
257
|
-
/**
|
|
258
|
-
* Steering block appended to the tail user message on turns whose
|
|
259
|
-
* triggering message carries `messageSource: "nav_redirect"` — a UI
|
|
260
|
-
* shortcut sent the message on the user's behalf while the user was
|
|
261
|
-
* exploring the interface. Without it the model treats the canned prompt
|
|
262
|
-
* as a hand-typed question and answers with a capability tour.
|
|
263
|
-
*/
|
|
264
|
-
const NAV_REDIRECT_STEERING_BLOCK =
|
|
265
|
-
"<system_reminder>\nThis message was sent by a UI shortcut while the user explored the interface, not typed by hand. Reply in a few sentences at most and end with one concrete, specific next step or question — do not enumerate capabilities or give a tour.\n</system_reminder>";
|
|
266
|
-
|
|
267
257
|
export async function runAgentLoopImpl(
|
|
268
258
|
ctx: Conversation,
|
|
269
259
|
content: string,
|
|
@@ -280,15 +270,6 @@ export async function runAgentLoopImpl(
|
|
|
280
270
|
* the turn.
|
|
281
271
|
*/
|
|
282
272
|
isHiddenPrompt?: boolean;
|
|
283
|
-
/**
|
|
284
|
-
* How the triggering message was initiated when it was sent on the
|
|
285
|
-
* user's behalf by the UI rather than typed by hand (`body.source` on
|
|
286
|
-
* `POST /v1/messages`, persisted as `metadata.userMessageSource`).
|
|
287
|
-
* Current value: `"nav_redirect"`, which steers the turn toward a
|
|
288
|
-
* brief, concrete reply. Forwarded onto the user-prompt-submit hook
|
|
289
|
-
* context. Unset for hand-typed messages.
|
|
290
|
-
*/
|
|
291
|
-
messageSource?: string;
|
|
292
273
|
/**
|
|
293
274
|
* LLM call-site identifier threaded into the per-call provider config.
|
|
294
275
|
* Adapter callers (heartbeat, filing, scheduler, etc.) pass their own
|
|
@@ -1000,9 +981,6 @@ export async function runAgentLoopImpl(
|
|
|
1000
981
|
requestId: reqId,
|
|
1001
982
|
prompt: options?.titleText ?? content,
|
|
1002
983
|
isHiddenPrompt: options?.isHiddenPrompt === true,
|
|
1003
|
-
...(options?.messageSource
|
|
1004
|
-
? { messageSource: options.messageSource }
|
|
1005
|
-
: {}),
|
|
1006
984
|
originalMessages: Object.freeze([...ctx.messages]),
|
|
1007
985
|
latestMessages: ctx.messages,
|
|
1008
986
|
modelProfileKey,
|
|
@@ -1014,29 +992,7 @@ export async function runAgentLoopImpl(
|
|
|
1014
992
|
userPromptCtx,
|
|
1015
993
|
);
|
|
1016
994
|
latencyTracker.mark("prompt_hook_end");
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
// UI-shortcut steering: appended after the hook chain so it lands below
|
|
1020
|
-
// the assembled runtime injections on the tail user message, mirroring
|
|
1021
|
-
// the non-interactive block's placement in `applyRuntimeInjections`.
|
|
1022
|
-
// Turn-scoped nudge, deliberately not persisted for byte-exact reload
|
|
1023
|
-
// rehydration — losing it on reload costs one prefix-cache anchor, not
|
|
1024
|
-
// context correctness.
|
|
1025
|
-
if (options?.messageSource === "nav_redirect") {
|
|
1026
|
-
const userTail = runMessages[runMessages.length - 1];
|
|
1027
|
-
if (userTail && userTail.role === "user") {
|
|
1028
|
-
runMessages = [
|
|
1029
|
-
...runMessages.slice(0, -1),
|
|
1030
|
-
{
|
|
1031
|
-
...userTail,
|
|
1032
|
-
content: [
|
|
1033
|
-
...userTail.content,
|
|
1034
|
-
{ type: "text" as const, text: NAV_REDIRECT_STEERING_BLOCK },
|
|
1035
|
-
],
|
|
1036
|
-
},
|
|
1037
|
-
];
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
995
|
+
const runMessages = finalUserPromptCtx.latestMessages;
|
|
1040
996
|
|
|
1041
997
|
// Reset the manager's turn-scoped overflow-recovery ladder at the turn
|
|
1042
998
|
// boundary so a new turn starts the ladder fresh from the emergency rung.
|
|
@@ -23,7 +23,6 @@ import { extractPreferences } from "../notifications/preference-extractor.js";
|
|
|
23
23
|
import { createPreference } from "../notifications/preferences-store.js";
|
|
24
24
|
import {
|
|
25
25
|
addMessage,
|
|
26
|
-
getUserMessageSourceMetadata,
|
|
27
26
|
isHiddenMessageMetadata,
|
|
28
27
|
provenanceFromTrustContext,
|
|
29
28
|
recordConversationPersistedSeq,
|
|
@@ -1050,7 +1049,6 @@ async function drainSingleMessage(
|
|
|
1050
1049
|
isUserMessage?: boolean;
|
|
1051
1050
|
titleText?: string;
|
|
1052
1051
|
isHiddenPrompt?: boolean;
|
|
1053
|
-
messageSource?: string;
|
|
1054
1052
|
} = { isUserMessage: true };
|
|
1055
1053
|
if (next.isInteractive !== undefined) {
|
|
1056
1054
|
drainLoopOptions.isInteractive = next.isInteractive;
|
|
@@ -1061,10 +1059,6 @@ async function drainSingleMessage(
|
|
|
1061
1059
|
if (isHiddenMessageMetadata(next.metadata)) {
|
|
1062
1060
|
drainLoopOptions.isHiddenPrompt = true;
|
|
1063
1061
|
}
|
|
1064
|
-
const queuedMessageSource = getUserMessageSourceMetadata(next.metadata);
|
|
1065
|
-
if (queuedMessageSource !== undefined) {
|
|
1066
|
-
drainLoopOptions.messageSource = queuedMessageSource;
|
|
1067
|
-
}
|
|
1068
1062
|
|
|
1069
1063
|
conversation
|
|
1070
1064
|
.runAgentLoop(agentLoopContent, userMessageId, {
|
|
@@ -1506,7 +1500,6 @@ async function drainBatch(
|
|
|
1506
1500
|
isUserMessage?: boolean;
|
|
1507
1501
|
titleText?: string;
|
|
1508
1502
|
isHiddenPrompt?: boolean;
|
|
1509
|
-
messageSource?: string;
|
|
1510
1503
|
} = { isUserMessage: true };
|
|
1511
1504
|
// Source interactive flag from the last successfully-persisted sibling so
|
|
1512
1505
|
// a trailing failed tail doesn't flip the agent loop's interactivity.
|
|
@@ -1526,21 +1519,6 @@ async function drainBatch(
|
|
|
1526
1519
|
) {
|
|
1527
1520
|
drainLoopOptions.isHiddenPrompt = true;
|
|
1528
1521
|
}
|
|
1529
|
-
// Same all-or-nothing rule for the send-source steering: a batch counts
|
|
1530
|
-
// as UI-initiated only when every message in it carries the same source
|
|
1531
|
-
// tag — one hand-typed message justifies a full answer.
|
|
1532
|
-
const batchMessageSource =
|
|
1533
|
-
successfulBatch.length > 0
|
|
1534
|
-
? getUserMessageSourceMetadata(successfulBatch[0].metadata)
|
|
1535
|
-
: undefined;
|
|
1536
|
-
if (
|
|
1537
|
-
batchMessageSource !== undefined &&
|
|
1538
|
-
successfulBatch.every(
|
|
1539
|
-
(qm) => getUserMessageSourceMetadata(qm.metadata) === batchMessageSource,
|
|
1540
|
-
)
|
|
1541
|
-
) {
|
|
1542
|
-
drainLoopOptions.messageSource = batchMessageSource;
|
|
1543
|
-
}
|
|
1544
1522
|
|
|
1545
1523
|
// Fire-and-forget: runAgentLoop's finally block recursively calls drainQueue
|
|
1546
1524
|
// when this run completes. Mirrors drainSingleMessage.
|
|
@@ -2492,8 +2492,6 @@ export class Conversation {
|
|
|
2492
2492
|
titleText?: string;
|
|
2493
2493
|
/** See {@link runAgentLoopImpl} — hidden machine-signal turn marker. */
|
|
2494
2494
|
isHiddenPrompt?: boolean;
|
|
2495
|
-
/** See {@link runAgentLoopImpl} — UI-initiated send-source tag. */
|
|
2496
|
-
messageSource?: string;
|
|
2497
2495
|
callSite?: LLMCallSite;
|
|
2498
2496
|
/**
|
|
2499
2497
|
* Optional ad-hoc inference-profile override applied to every LLM call
|
package/src/hooks/types.ts
CHANGED
|
@@ -173,15 +173,6 @@ export interface UserPromptSubmitInputContext {
|
|
|
173
173
|
* user speech (e.g. title generation) should skip these turns.
|
|
174
174
|
*/
|
|
175
175
|
readonly isHiddenPrompt?: boolean;
|
|
176
|
-
/**
|
|
177
|
-
* How the triggering message was initiated when it was sent on the
|
|
178
|
-
* user's behalf by the UI rather than typed by hand (`body.source` on
|
|
179
|
-
* `POST /v1/messages`, persisted as `metadata.userMessageSource`).
|
|
180
|
-
* Current value: `"nav_redirect"` — a navigation shortcut redirected the
|
|
181
|
-
* user into chat with a pre-filled message. Unset for hand-typed
|
|
182
|
-
* messages.
|
|
183
|
-
*/
|
|
184
|
-
readonly messageSource?: string;
|
|
185
176
|
/**
|
|
186
177
|
* The user's original message list, immutable for the hook. Plugins
|
|
187
178
|
* may snapshot or compare against this but MUST NOT mutate it.
|
|
@@ -660,7 +660,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
660
660
|
objective: string;
|
|
661
661
|
label: string;
|
|
662
662
|
signal: AbortSignal;
|
|
663
|
-
}) =>
|
|
663
|
+
}): Promise<string> => "",
|
|
664
664
|
);
|
|
665
665
|
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
666
666
|
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
@@ -699,9 +699,9 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
699
699
|
objective: string;
|
|
700
700
|
label: string;
|
|
701
701
|
signal: AbortSignal;
|
|
702
|
-
}) =>
|
|
703
|
-
new Promise<
|
|
704
|
-
args.signal.addEventListener("abort", () => resolve(), {
|
|
702
|
+
}): Promise<string> =>
|
|
703
|
+
new Promise<string>((resolve) => {
|
|
704
|
+
args.signal.addEventListener("abort", () => resolve(""), {
|
|
705
705
|
once: true,
|
|
706
706
|
});
|
|
707
707
|
}),
|
|
@@ -739,7 +739,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
739
739
|
objective: string;
|
|
740
740
|
label: string;
|
|
741
741
|
signal: AbortSignal;
|
|
742
|
-
}) =>
|
|
742
|
+
}): Promise<string> => "",
|
|
743
743
|
);
|
|
744
744
|
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
745
745
|
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
@@ -789,7 +789,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
789
789
|
objective: string;
|
|
790
790
|
label: string;
|
|
791
791
|
signal: AbortSignal;
|
|
792
|
-
}) =>
|
|
792
|
+
}): Promise<string> => "",
|
|
793
793
|
);
|
|
794
794
|
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
795
795
|
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
@@ -821,7 +821,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
821
821
|
objective: string;
|
|
822
822
|
label: string;
|
|
823
823
|
signal: AbortSignal;
|
|
824
|
-
}) =>
|
|
824
|
+
}): Promise<string> => "",
|
|
825
825
|
);
|
|
826
826
|
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
827
827
|
callbacks ??= options.callbacks;
|
|
@@ -873,7 +873,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
873
873
|
objective: string;
|
|
874
874
|
label: string;
|
|
875
875
|
signal: AbortSignal;
|
|
876
|
-
}) =>
|
|
876
|
+
}): Promise<string> => "",
|
|
877
877
|
);
|
|
878
878
|
// Hold the interrupted turn's teardown open. Its partial (completed tool
|
|
879
879
|
// calls) is only in forked history once the teardown settles, so the fork
|
|
@@ -922,7 +922,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
922
922
|
objective: string;
|
|
923
923
|
label: string;
|
|
924
924
|
signal: AbortSignal;
|
|
925
|
-
}) =>
|
|
925
|
+
}): Promise<string> => "",
|
|
926
926
|
);
|
|
927
927
|
// A teardown that never settles: the bounded wait times out, and the fork
|
|
928
928
|
// must be skipped rather than snapshot history that may still be missing the
|
|
@@ -964,7 +964,7 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
964
964
|
objective: string;
|
|
965
965
|
label: string;
|
|
966
966
|
signal: AbortSignal;
|
|
967
|
-
}) =>
|
|
967
|
+
}): Promise<string> => "",
|
|
968
968
|
);
|
|
969
969
|
// Never resolves: the detach is parked in the teardown wait until the stop
|
|
970
970
|
// aborts it.
|
|
@@ -1002,6 +1002,392 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
1002
1002
|
expect(spawnBackgroundContinuation).not.toHaveBeenCalled();
|
|
1003
1003
|
});
|
|
1004
1004
|
|
|
1005
|
+
// A startVoiceTurn stub that keeps the first turn "thinking" (so a barge-in
|
|
1006
|
+
// can land on it) but auto-completes every later turn so the single-turn lock
|
|
1007
|
+
// frees for the next utterance. Records each turn's options for inspection.
|
|
1008
|
+
function makeResurfaceTurnStarter(): {
|
|
1009
|
+
startVoiceTurn: LiveVoiceTurnStarter;
|
|
1010
|
+
calls: VoiceTurnOptions[];
|
|
1011
|
+
} {
|
|
1012
|
+
const calls: VoiceTurnOptions[] = [];
|
|
1013
|
+
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
1014
|
+
calls.push(options);
|
|
1015
|
+
if (options.content !== "first question") {
|
|
1016
|
+
options.callbacks?.assistant_text_delta?.(makeTextDelta("ok"));
|
|
1017
|
+
options.callbacks?.message_complete?.(makeMessageComplete());
|
|
1018
|
+
}
|
|
1019
|
+
return { turnId: `bridge-turn-${calls.length}`, abort: mock() };
|
|
1020
|
+
});
|
|
1021
|
+
return { startVoiceTurn, calls };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
test("voice-duplex-handoff on: a completed continuation's result folds into the next turn's control prompt", async () => {
|
|
1025
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1026
|
+
// Control the continuation's resolution so we know exactly when its result
|
|
1027
|
+
// is stashed relative to the turns we inspect.
|
|
1028
|
+
let resolveContinuation: ((result: string) => void) | undefined;
|
|
1029
|
+
const spawnBackgroundContinuation = mock(
|
|
1030
|
+
(_args: {
|
|
1031
|
+
parentConversationId: string;
|
|
1032
|
+
objective: string;
|
|
1033
|
+
label: string;
|
|
1034
|
+
signal: AbortSignal;
|
|
1035
|
+
}): Promise<string> =>
|
|
1036
|
+
new Promise<string>((resolve) => {
|
|
1037
|
+
resolveContinuation = resolve;
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
1041
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1042
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1043
|
+
return makeTtsResult("assistant audio");
|
|
1044
|
+
});
|
|
1045
|
+
const { frames, session } = createHarness({
|
|
1046
|
+
finals: [
|
|
1047
|
+
"first question",
|
|
1048
|
+
"second question",
|
|
1049
|
+
"third question",
|
|
1050
|
+
"fourth question",
|
|
1051
|
+
],
|
|
1052
|
+
startVoiceTurn,
|
|
1053
|
+
streamTtsAudio,
|
|
1054
|
+
spawnBackgroundContinuation,
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
await session.start();
|
|
1058
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1059
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1060
|
+
|
|
1061
|
+
// Barge in during thinking: turn 1 is cancelled and its continuation spawns
|
|
1062
|
+
// (but has not resolved). The barge-in follow-up turn launches meanwhile,
|
|
1063
|
+
// carrying the merge note but no continuation result yet.
|
|
1064
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1065
|
+
await waitFor(() => resolveContinuation !== undefined);
|
|
1066
|
+
await waitFor(() => calls.some((c) => c.content === "second question"));
|
|
1067
|
+
const followUp = calls.find((c) => c.content === "second question");
|
|
1068
|
+
expect(followUp?.voiceControlPrompt).toContain("interrupted");
|
|
1069
|
+
expect(followUp?.voiceControlPrompt).not.toContain("THE_RESULT");
|
|
1070
|
+
|
|
1071
|
+
// The continuation finishes; its answer is stashed for the next turn.
|
|
1072
|
+
resolveContinuation?.("THE_RESULT");
|
|
1073
|
+
await flushAsyncCallbacks();
|
|
1074
|
+
|
|
1075
|
+
// The next turn the user starts folds the continuation's answer in as
|
|
1076
|
+
// context (never spoken on its own).
|
|
1077
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1078
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1079
|
+
const resurfaced = calls.find((c) => c.content === "third question");
|
|
1080
|
+
expect(resurfaced?.voiceControlPrompt).toContain("THE_RESULT");
|
|
1081
|
+
expect(resurfaced?.voiceControlPrompt).toContain("background");
|
|
1082
|
+
|
|
1083
|
+
// Consume-once: a later turn does not repeat it.
|
|
1084
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1085
|
+
await waitFor(() => calls.some((c) => c.content === "fourth question"));
|
|
1086
|
+
const later = calls.find((c) => c.content === "fourth question");
|
|
1087
|
+
expect(later?.voiceControlPrompt).not.toContain("THE_RESULT");
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test("voice-duplex-handoff on: a client interrupt drops a stashed continuation result", async () => {
|
|
1091
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1092
|
+
let resolveContinuation: ((result: string) => void) | undefined;
|
|
1093
|
+
const spawnBackgroundContinuation = mock(
|
|
1094
|
+
(_args: {
|
|
1095
|
+
parentConversationId: string;
|
|
1096
|
+
objective: string;
|
|
1097
|
+
label: string;
|
|
1098
|
+
signal: AbortSignal;
|
|
1099
|
+
}): Promise<string> =>
|
|
1100
|
+
new Promise<string>((resolve) => {
|
|
1101
|
+
resolveContinuation = resolve;
|
|
1102
|
+
}),
|
|
1103
|
+
);
|
|
1104
|
+
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
1105
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1106
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1107
|
+
return makeTtsResult("assistant audio");
|
|
1108
|
+
});
|
|
1109
|
+
// The barge-in utterance transcribes to nothing, so no follow-up turn
|
|
1110
|
+
// consumes the stash before the interrupt lands.
|
|
1111
|
+
const { frames, session } = createHarness({
|
|
1112
|
+
finals: ["first question", "", "third question"],
|
|
1113
|
+
startVoiceTurn,
|
|
1114
|
+
streamTtsAudio,
|
|
1115
|
+
spawnBackgroundContinuation,
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
await session.start();
|
|
1119
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1120
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1121
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1122
|
+
await waitFor(() => resolveContinuation !== undefined);
|
|
1123
|
+
|
|
1124
|
+
// The continuation finishes and stashes its result...
|
|
1125
|
+
resolveContinuation?.("THE_RESULT");
|
|
1126
|
+
await flushAsyncCallbacks();
|
|
1127
|
+
|
|
1128
|
+
// ...but a client interrupt is a hard stop that drops it.
|
|
1129
|
+
await session.handleClientFrame({ type: "interrupt" });
|
|
1130
|
+
await flushAsyncCallbacks();
|
|
1131
|
+
|
|
1132
|
+
// The next turn carries no resurfaced result.
|
|
1133
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1134
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1135
|
+
const later = calls.find((c) => c.content === "third question");
|
|
1136
|
+
expect(later?.voiceControlPrompt).not.toContain("THE_RESULT");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("voice-duplex-handoff on: an empty continuation result adds no context to the next turn", async () => {
|
|
1140
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1141
|
+
// Continuation ends with no answer text (e.g. it stopped on a tool call):
|
|
1142
|
+
// there is nothing to fold in.
|
|
1143
|
+
const spawnBackgroundContinuation = mock(
|
|
1144
|
+
async (_args: {
|
|
1145
|
+
parentConversationId: string;
|
|
1146
|
+
objective: string;
|
|
1147
|
+
label: string;
|
|
1148
|
+
signal: AbortSignal;
|
|
1149
|
+
}): Promise<string> => "",
|
|
1150
|
+
);
|
|
1151
|
+
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
1152
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1153
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1154
|
+
return makeTtsResult("assistant audio");
|
|
1155
|
+
});
|
|
1156
|
+
const { frames, session } = createHarness({
|
|
1157
|
+
finals: ["first question", "second question", "third question"],
|
|
1158
|
+
startVoiceTurn,
|
|
1159
|
+
streamTtsAudio,
|
|
1160
|
+
spawnBackgroundContinuation,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
await session.start();
|
|
1164
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1165
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1166
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1167
|
+
await waitFor(() => spawnBackgroundContinuation.mock.calls.length === 1);
|
|
1168
|
+
// Let the barge-in follow-up turn launch and complete before the next
|
|
1169
|
+
// utterance, so the follow-on turn forms cleanly.
|
|
1170
|
+
await waitFor(() => calls.some((c) => c.content === "second question"));
|
|
1171
|
+
await flushAsyncCallbacks();
|
|
1172
|
+
|
|
1173
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1174
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1175
|
+
const later = calls.find((c) => c.content === "third question");
|
|
1176
|
+
expect(later?.voiceControlPrompt).not.toContain("background you finished");
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
test("voice-duplex-handoff on: a newer continuation's result survives an older one finishing later", async () => {
|
|
1180
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1181
|
+
// Capture each continuation's resolver in spawn (detach) order.
|
|
1182
|
+
const resolvers: Array<(result: string) => void> = [];
|
|
1183
|
+
const spawnBackgroundContinuation = mock(
|
|
1184
|
+
(_args: {
|
|
1185
|
+
parentConversationId: string;
|
|
1186
|
+
objective: string;
|
|
1187
|
+
label: string;
|
|
1188
|
+
signal: AbortSignal;
|
|
1189
|
+
}): Promise<string> =>
|
|
1190
|
+
new Promise<string>((resolve) => {
|
|
1191
|
+
resolvers.push(resolve);
|
|
1192
|
+
}),
|
|
1193
|
+
);
|
|
1194
|
+
// Keep the first two turns "thinking" so both can be barged; auto-complete
|
|
1195
|
+
// the rest so the lock frees for later utterances.
|
|
1196
|
+
const calls: VoiceTurnOptions[] = [];
|
|
1197
|
+
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
1198
|
+
calls.push(options);
|
|
1199
|
+
if (
|
|
1200
|
+
options.content !== "first question" &&
|
|
1201
|
+
options.content !== "second question"
|
|
1202
|
+
) {
|
|
1203
|
+
options.callbacks?.assistant_text_delta?.(makeTextDelta("ok"));
|
|
1204
|
+
options.callbacks?.message_complete?.(makeMessageComplete());
|
|
1205
|
+
}
|
|
1206
|
+
return { turnId: `bridge-turn-${calls.length}`, abort: mock() };
|
|
1207
|
+
});
|
|
1208
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1209
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1210
|
+
return makeTtsResult("assistant audio");
|
|
1211
|
+
});
|
|
1212
|
+
const { frames, session } = createHarness({
|
|
1213
|
+
finals: [
|
|
1214
|
+
"first question",
|
|
1215
|
+
"second question",
|
|
1216
|
+
"third question",
|
|
1217
|
+
"fourth question",
|
|
1218
|
+
],
|
|
1219
|
+
startVoiceTurn,
|
|
1220
|
+
streamTtsAudio,
|
|
1221
|
+
spawnBackgroundContinuation,
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
await session.start();
|
|
1225
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1226
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1227
|
+
|
|
1228
|
+
// Barge #1 detaches the older continuation; its follow-up turn stays thinking
|
|
1229
|
+
// so barge #2 can detach the newer continuation.
|
|
1230
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1231
|
+
await waitFor(() => calls.some((c) => c.content === "second question"));
|
|
1232
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1233
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1234
|
+
await waitFor(() => resolvers.length === 2);
|
|
1235
|
+
|
|
1236
|
+
// The newer continuation finishes first, then the older one finishes late.
|
|
1237
|
+
// The sequence guard keeps the newer result regardless of completion order.
|
|
1238
|
+
resolvers[1]?.("NEWER_RESULT");
|
|
1239
|
+
resolvers[0]?.("OLDER_RESULT");
|
|
1240
|
+
await flushAsyncCallbacks();
|
|
1241
|
+
|
|
1242
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1243
|
+
await waitFor(() => calls.some((c) => c.content === "fourth question"));
|
|
1244
|
+
const resurfaced = calls.find((c) => c.content === "fourth question");
|
|
1245
|
+
expect(resurfaced?.voiceControlPrompt).toContain("NEWER_RESULT");
|
|
1246
|
+
expect(resurfaced?.voiceControlPrompt).not.toContain("OLDER_RESULT");
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test("voice-duplex-handoff on: a rapid second barge-in invalidates a first continuation that finishes after it", async () => {
|
|
1250
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1251
|
+
const resolvers: Array<(result: string) => void> = [];
|
|
1252
|
+
const spawnBackgroundContinuation = mock(
|
|
1253
|
+
(_args: {
|
|
1254
|
+
parentConversationId: string;
|
|
1255
|
+
objective: string;
|
|
1256
|
+
label: string;
|
|
1257
|
+
signal: AbortSignal;
|
|
1258
|
+
}): Promise<string> =>
|
|
1259
|
+
new Promise<string>((resolve) => {
|
|
1260
|
+
resolvers.push(resolve);
|
|
1261
|
+
}),
|
|
1262
|
+
);
|
|
1263
|
+
const calls: VoiceTurnOptions[] = [];
|
|
1264
|
+
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
1265
|
+
calls.push(options);
|
|
1266
|
+
if (
|
|
1267
|
+
options.content !== "first question" &&
|
|
1268
|
+
options.content !== "second question"
|
|
1269
|
+
) {
|
|
1270
|
+
options.callbacks?.assistant_text_delta?.(makeTextDelta("ok"));
|
|
1271
|
+
options.callbacks?.message_complete?.(makeMessageComplete());
|
|
1272
|
+
}
|
|
1273
|
+
return { turnId: `bridge-turn-${calls.length}`, abort: mock() };
|
|
1274
|
+
});
|
|
1275
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1276
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1277
|
+
return makeTtsResult("assistant audio");
|
|
1278
|
+
});
|
|
1279
|
+
const { frames, session } = createHarness({
|
|
1280
|
+
finals: [
|
|
1281
|
+
"first question",
|
|
1282
|
+
"second question",
|
|
1283
|
+
"third question",
|
|
1284
|
+
"fourth question",
|
|
1285
|
+
],
|
|
1286
|
+
startVoiceTurn,
|
|
1287
|
+
streamTtsAudio,
|
|
1288
|
+
spawnBackgroundContinuation,
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
await session.start();
|
|
1292
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1293
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1294
|
+
// Barge #1 detaches continuation A (older); its follow-up turn stays thinking
|
|
1295
|
+
// so barge #2 can land on it.
|
|
1296
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1297
|
+
await waitFor(() => resolvers.length === 1);
|
|
1298
|
+
await waitFor(() => calls.some((c) => c.content === "second question"));
|
|
1299
|
+
// Barge #2 is a rapid second interruption: it bumps the sequence
|
|
1300
|
+
// synchronously, so A is invalidated even before barge #2's own async detach
|
|
1301
|
+
// runs.
|
|
1302
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1303
|
+
// A finishes AFTER barge #2 and must be rejected. The newer continuation is
|
|
1304
|
+
// left pending so it can't mask the bug by superseding A itself.
|
|
1305
|
+
resolvers[0]?.("OLDER_RESULT");
|
|
1306
|
+
await flushAsyncCallbacks();
|
|
1307
|
+
|
|
1308
|
+
// A later turn carries no stale older result.
|
|
1309
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1310
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1311
|
+
await waitFor(() => calls.some((c) => c.content === "fourth question"));
|
|
1312
|
+
const resurfaced = calls.find((c) => c.content === "fourth question");
|
|
1313
|
+
expect(resurfaced?.voiceControlPrompt).not.toContain("OLDER_RESULT");
|
|
1314
|
+
expect(resurfaced?.voiceControlPrompt).not.toContain(
|
|
1315
|
+
"background you finished",
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
// Cleanup the still-pending newer continuation.
|
|
1319
|
+
await waitFor(() => resolvers.length === 2);
|
|
1320
|
+
resolvers[1]?.("");
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test("voice-duplex-handoff on: a new barge-in drops an already-stashed continuation result", async () => {
|
|
1324
|
+
setCachedOverrides({ "voice-duplex-handoff": true }, { fromGateway: true });
|
|
1325
|
+
const resolvers: Array<(result: string) => void> = [];
|
|
1326
|
+
const spawnBackgroundContinuation = mock(
|
|
1327
|
+
(_args: {
|
|
1328
|
+
parentConversationId: string;
|
|
1329
|
+
objective: string;
|
|
1330
|
+
label: string;
|
|
1331
|
+
signal: AbortSignal;
|
|
1332
|
+
}): Promise<string> =>
|
|
1333
|
+
new Promise<string>((resolve) => {
|
|
1334
|
+
resolvers.push(resolve);
|
|
1335
|
+
}),
|
|
1336
|
+
);
|
|
1337
|
+
const calls: VoiceTurnOptions[] = [];
|
|
1338
|
+
const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
|
|
1339
|
+
calls.push(options);
|
|
1340
|
+
if (
|
|
1341
|
+
options.content !== "first question" &&
|
|
1342
|
+
options.content !== "second question"
|
|
1343
|
+
) {
|
|
1344
|
+
options.callbacks?.assistant_text_delta?.(makeTextDelta("ok"));
|
|
1345
|
+
options.callbacks?.message_complete?.(makeMessageComplete());
|
|
1346
|
+
}
|
|
1347
|
+
return { turnId: `bridge-turn-${calls.length}`, abort: mock() };
|
|
1348
|
+
});
|
|
1349
|
+
const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => {
|
|
1350
|
+
options.onAudioChunk(makeTtsChunk("assistant audio"));
|
|
1351
|
+
return makeTtsResult("assistant audio");
|
|
1352
|
+
});
|
|
1353
|
+
const { frames, session } = createHarness({
|
|
1354
|
+
finals: [
|
|
1355
|
+
"first question",
|
|
1356
|
+
"second question",
|
|
1357
|
+
"third question",
|
|
1358
|
+
"fourth question",
|
|
1359
|
+
],
|
|
1360
|
+
startVoiceTurn,
|
|
1361
|
+
streamTtsAudio,
|
|
1362
|
+
spawnBackgroundContinuation,
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
await session.start();
|
|
1366
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1367
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1368
|
+
|
|
1369
|
+
// Barge #1 detaches continuation A; it finishes and stashes while it is the
|
|
1370
|
+
// latest detach.
|
|
1371
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1372
|
+
await waitFor(() => calls.some((c) => c.content === "second question"));
|
|
1373
|
+
await waitFor(() => resolvers.length === 1);
|
|
1374
|
+
resolvers[0]?.("A_RESULT");
|
|
1375
|
+
await flushAsyncCallbacks();
|
|
1376
|
+
|
|
1377
|
+
// Barge #2 is a fresh interruption: it must drop A's already-stashed result
|
|
1378
|
+
// (and detach the newer continuation).
|
|
1379
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1380
|
+
await waitFor(() => calls.some((c) => c.content === "third question"));
|
|
1381
|
+
await waitFor(() => resolvers.length === 2);
|
|
1382
|
+
|
|
1383
|
+
// The barge #2 follow-up turn carries no stale A result.
|
|
1384
|
+
const followUp = calls.find((c) => c.content === "third question");
|
|
1385
|
+
expect(followUp?.voiceControlPrompt).not.toContain("A_RESULT");
|
|
1386
|
+
|
|
1387
|
+
// Cleanup the still-pending newer continuation.
|
|
1388
|
+
resolvers[1]?.("");
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1005
1391
|
test("a late assistant_text_delta after a thinking barge-in never reaches the client", async () => {
|
|
1006
1392
|
let callbacks: VoiceTurnCallbacks | undefined;
|
|
1007
1393
|
const abort = mock();
|
|
@@ -132,16 +132,17 @@ export type LiveVoiceTtsStreamer = (
|
|
|
132
132
|
) => Promise<LiveVoiceTtsResult>;
|
|
133
133
|
|
|
134
134
|
// Runs an interrupted live-voice turn to completion on a background subagent
|
|
135
|
-
// (true-duplex handoff, gated behind voice-duplex-handoff). The run
|
|
136
|
-
// (no parent notification
|
|
137
|
-
//
|
|
138
|
-
//
|
|
135
|
+
// (true-duplex handoff, gated behind voice-duplex-handoff). The run stays silent
|
|
136
|
+
// (no parent notification, never spoken unprompted); it RESOLVES with the
|
|
137
|
+
// continuation's final answer text, which the session folds into the next turn
|
|
138
|
+
// the user starts as context. Aborts when `signal` fires (rejecting). Injected
|
|
139
|
+
// for testability; the factory wires the real SubagentManager-backed impl.
|
|
139
140
|
export type LiveVoiceBackgroundContinuationSpawner = (args: {
|
|
140
141
|
parentConversationId: string;
|
|
141
142
|
objective: string;
|
|
142
143
|
label: string;
|
|
143
144
|
signal: AbortSignal;
|
|
144
|
-
}) => Promise<
|
|
145
|
+
}) => Promise<string>;
|
|
145
146
|
|
|
146
147
|
export interface LiveVoiceSessionArchiveAudioInput {
|
|
147
148
|
messageId?: string | null;
|
|
@@ -314,6 +315,11 @@ interface ActiveAssistantTurn {
|
|
|
314
315
|
// merges it with this turn's utterance instead of treating that utterance as
|
|
315
316
|
// a fresh follow-up. Null for an ordinary (non-barge-in) turn.
|
|
316
317
|
interruptedRequest: string | null;
|
|
318
|
+
// The completed background continuation's answer (voice-duplex-handoff),
|
|
319
|
+
// folded into this turn's control prompt as context so the model can deliver
|
|
320
|
+
// or reference it in reply to the user, without ever speaking it unprompted.
|
|
321
|
+
// Null when no continuation result is pending for this turn.
|
|
322
|
+
continuationResult: string | null;
|
|
317
323
|
// Triage-and-escalate (Voice Mode): the front-door leg emitted [ESCALATE]
|
|
318
324
|
// and the strong "escalated" leg has taken over this same turn. Guards the
|
|
319
325
|
// front-door leg's trailing completion from finalizing the turn, and makes
|
|
@@ -351,6 +357,31 @@ function buildInterruptionMergeNote(interruptedRequest: string): string {
|
|
|
351
357
|
return `The user interrupted your previous, unfinished reply. Their earlier request was: "${interruptedRequest}". Treat their current message as a continuation of that request and address both together, or stay silent if they only want you to stop.`;
|
|
352
358
|
}
|
|
353
359
|
|
|
360
|
+
// System-level guidance appended to the NEXT turn's control prompt after a
|
|
361
|
+
// background continuation (voice-duplex-handoff) finished the reply the user
|
|
362
|
+
// interrupted. Folds the completed answer in as context so the model can
|
|
363
|
+
// deliver or reference it in reply to the user; it must never be spoken
|
|
364
|
+
// unprompted (live-voice is non-interactive, JARVIS-1291). Reaches the model
|
|
365
|
+
// only; it is not a user message and never renders as a transcript bubble.
|
|
366
|
+
function buildResurfaceContextNote(continuationResult: string): string {
|
|
367
|
+
return `Earlier the user interrupted you, and in the background you finished the reply they cut off. What you worked out was: "${continuationResult}". If their current message relates to it, use it to answer; otherwise you may briefly offer it or leave it aside, and do not repeat it verbatim if it no longer fits.`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Assemble a turn's model-facing control prompt: the base live-voice rules plus
|
|
371
|
+
// any pending barge-in merge context and/or completed-continuation context. A
|
|
372
|
+
// turn can carry both (a barge-in follow-up that also has a continuation result
|
|
373
|
+
// waiting); the notes are model-only and never render as user bubbles.
|
|
374
|
+
function buildVoiceControlPrompt(turn: ActiveAssistantTurn): string {
|
|
375
|
+
let prompt = LIVE_VOICE_CONTROL_PROMPT;
|
|
376
|
+
if (turn.interruptedRequest) {
|
|
377
|
+
prompt = `${prompt}\n\n${buildInterruptionMergeNote(turn.interruptedRequest)}`;
|
|
378
|
+
}
|
|
379
|
+
if (turn.continuationResult) {
|
|
380
|
+
prompt = `${prompt}\n\n${buildResurfaceContextNote(turn.continuationResult)}`;
|
|
381
|
+
}
|
|
382
|
+
return prompt;
|
|
383
|
+
}
|
|
384
|
+
|
|
354
385
|
// Objective handed to the background subagent that continues a barged-in turn.
|
|
355
386
|
// The subagent forks the live conversation, so it already sees the interrupted
|
|
356
387
|
// turn's completed tool calls in history and resumes from there. The request
|
|
@@ -403,6 +434,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
403
434
|
// before its async teardown; if it has changed by the time the detach would
|
|
404
435
|
// spawn, a stop landed during the gap and the continuation is not started.
|
|
405
436
|
private detachStopGeneration = 0;
|
|
437
|
+
// Bumped SYNCHRONOUSLY at each barge-in (in barge order), before the async
|
|
438
|
+
// detach runs. Only the latest-started detach (detachSeq === detachSequence)
|
|
439
|
+
// may populate the pending result, so once a newer barge-in starts, an older
|
|
440
|
+
// continuation completing (before OR after it) can't surface a stale answer.
|
|
441
|
+
private detachSequence = 0;
|
|
406
442
|
private readonly emitMetrics: boolean;
|
|
407
443
|
private readonly metrics: LiveVoiceMetricsCollector;
|
|
408
444
|
private readonly createTurnId: () => string;
|
|
@@ -444,6 +480,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
444
480
|
// Consumed (and cleared) when that turn launches; cleared if the barge-in
|
|
445
481
|
// utterance is discarded, so it can never attach to a later, unrelated turn.
|
|
446
482
|
private pendingInterruptedRequest: string | null = null;
|
|
483
|
+
// Set when a background continuation (voice-duplex-handoff) finishes the reply
|
|
484
|
+
// a barge-in cut off: its final answer, folded into the next turn the user
|
|
485
|
+
// starts as context. Consumed (and cleared) when that turn launches; cleared
|
|
486
|
+
// on a hard stop (abortDetachedRuns) so a stale result never surfaces later.
|
|
487
|
+
private pendingContinuationResult: string | null = null;
|
|
447
488
|
private readonly maxPendingAudioBytes: number;
|
|
448
489
|
// Set on VAD speech onset; consumed when the first speech chunk is routed
|
|
449
490
|
// to an utterance so the metric lands on the right turn.
|
|
@@ -1104,6 +1145,17 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1104
1145
|
.trim();
|
|
1105
1146
|
this.pendingInterruptedRequest =
|
|
1106
1147
|
interruptedRequest.length > 0 ? interruptedRequest : null;
|
|
1148
|
+
// A fresh interruption supersedes any already-stashed continuation result:
|
|
1149
|
+
// drop it synchronously here so the barge-in follow-up (or any later) turn
|
|
1150
|
+
// can't consume an older answer before this barge-in's own continuation
|
|
1151
|
+
// completes. Cleared even when no continuation ultimately detaches.
|
|
1152
|
+
this.pendingContinuationResult = null;
|
|
1153
|
+
// Order this barge-in among concurrent detaches SYNCHRONOUSLY, in barge
|
|
1154
|
+
// order — the actual detach runs after an async teardown chain, and those
|
|
1155
|
+
// chains can interleave, so bumping there could assign sequences out of
|
|
1156
|
+
// order and let an older continuation re-stash. A higher sequence here
|
|
1157
|
+
// immediately invalidates every earlier still-running continuation.
|
|
1158
|
+
const detachSeq = ++this.detachSequence;
|
|
1107
1159
|
turn.abortController.abort();
|
|
1108
1160
|
this.metrics.markBargeIn(turn.turnId);
|
|
1109
1161
|
// Capture the interrupted turn's teardown promise synchronously, before the
|
|
@@ -1128,7 +1180,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1128
1180
|
// Keep the interrupted turn's work alive on a background subagent
|
|
1129
1181
|
// (voice-duplex-handoff); the detach waits for its teardown to settle the
|
|
1130
1182
|
// partial into history before forking.
|
|
1131
|
-
this.detachInterruptedTurn(turn, stopGeneration, teardownWait);
|
|
1183
|
+
this.detachInterruptedTurn(turn, stopGeneration, teardownWait, detachSeq);
|
|
1132
1184
|
})().catch(() => {});
|
|
1133
1185
|
}
|
|
1134
1186
|
|
|
@@ -1144,6 +1196,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1144
1196
|
turn: ActiveAssistantTurn,
|
|
1145
1197
|
stopGeneration: number,
|
|
1146
1198
|
teardownWait: Promise<void> | undefined,
|
|
1199
|
+
detachSeq: number,
|
|
1147
1200
|
): void {
|
|
1148
1201
|
const spawn = this.spawnBackgroundContinuation;
|
|
1149
1202
|
if (
|
|
@@ -1204,12 +1257,28 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1204
1257
|
if (controller.signal.aborted || this.isClosed) {
|
|
1205
1258
|
return;
|
|
1206
1259
|
}
|
|
1207
|
-
await spawn({
|
|
1260
|
+
const resultText = await spawn({
|
|
1208
1261
|
parentConversationId: this.conversationId,
|
|
1209
1262
|
objective: buildDuplexContinuationObjective(interruptedRequest),
|
|
1210
1263
|
label: `voice-continue-${turn.turnId}`,
|
|
1211
1264
|
signal: controller.signal,
|
|
1212
1265
|
});
|
|
1266
|
+
// Fold the completed continuation's answer into the next turn the user
|
|
1267
|
+
// starts (never spoken unprompted). Re-check the stop guards after the
|
|
1268
|
+
// await: an interrupt/close during the run must suppress it. Only the
|
|
1269
|
+
// latest-started detach may populate the result, so once a newer
|
|
1270
|
+
// barge-in has started, an older continuation completing (before or
|
|
1271
|
+
// after it, empty or not) can't surface a stale answer. Only non-empty
|
|
1272
|
+
// text is actually surfaced.
|
|
1273
|
+
if (
|
|
1274
|
+
!controller.signal.aborted &&
|
|
1275
|
+
!this.isClosed &&
|
|
1276
|
+
this.detachStopGeneration === stopGeneration &&
|
|
1277
|
+
detachSeq === this.detachSequence
|
|
1278
|
+
) {
|
|
1279
|
+
const answer = resultText.trim();
|
|
1280
|
+
this.pendingContinuationResult = answer.length > 0 ? answer : null;
|
|
1281
|
+
}
|
|
1213
1282
|
} catch (err) {
|
|
1214
1283
|
// A stop/interrupt aborts via the signal; that rejection is expected.
|
|
1215
1284
|
if (!controller.signal.aborted) {
|
|
@@ -1235,6 +1304,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1235
1304
|
controller.abort();
|
|
1236
1305
|
}
|
|
1237
1306
|
this.detachControllers.clear();
|
|
1307
|
+
// A hard stop also drops any completed continuation's result still waiting
|
|
1308
|
+
// to fold into the next turn, so it can't surface after the user reset.
|
|
1309
|
+
this.pendingContinuationResult = null;
|
|
1238
1310
|
}
|
|
1239
1311
|
|
|
1240
1312
|
// VAD closed the utterance — the analog of ptt_release: emit
|
|
@@ -1747,11 +1819,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1747
1819
|
return;
|
|
1748
1820
|
}
|
|
1749
1821
|
|
|
1750
|
-
// Consume any pending barge-in merge context
|
|
1751
|
-
// feeds exactly the next launched turn).
|
|
1822
|
+
// Consume any pending barge-in merge context and completed-continuation
|
|
1823
|
+
// context for this turn (each feeds exactly the next launched turn).
|
|
1752
1824
|
const interruptedRequest = this.pendingInterruptedRequest;
|
|
1753
1825
|
this.pendingInterruptedRequest = null;
|
|
1754
|
-
|
|
1826
|
+
const continuationResult = this.pendingContinuationResult;
|
|
1827
|
+
this.pendingContinuationResult = null;
|
|
1828
|
+
await this.launchAssistantTurn(utterance, content, {
|
|
1829
|
+
interruptedRequest,
|
|
1830
|
+
continuationResult,
|
|
1831
|
+
});
|
|
1755
1832
|
}
|
|
1756
1833
|
|
|
1757
1834
|
// Build the ActiveAssistantTurn for a released utterance and drive its model
|
|
@@ -1763,6 +1840,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1763
1840
|
// Set on a barge-in follow-up turn: the interrupted request's transcript,
|
|
1764
1841
|
// appended to the turn's control prompt so the model merges the two.
|
|
1765
1842
|
interruptedRequest?: string | null;
|
|
1843
|
+
// Set when a background continuation finished the interrupted reply: its
|
|
1844
|
+
// answer, appended to the turn's control prompt as context.
|
|
1845
|
+
continuationResult?: string | null;
|
|
1766
1846
|
},
|
|
1767
1847
|
): Promise<void> {
|
|
1768
1848
|
utterance.assistantTurnStarted = true;
|
|
@@ -1781,6 +1861,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1781
1861
|
ttsAudioStarted: false,
|
|
1782
1862
|
finalized: false,
|
|
1783
1863
|
interruptedRequest: opts?.interruptedRequest ?? null,
|
|
1864
|
+
continuationResult: opts?.continuationResult ?? null,
|
|
1784
1865
|
escalationHandedOff: false,
|
|
1785
1866
|
ttsBuffer: "",
|
|
1786
1867
|
ttsSegmentEnqueued: false,
|
|
@@ -1913,11 +1994,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1913
1994
|
assistantMessageChannel: "vellum",
|
|
1914
1995
|
userMessageInterface: "macos",
|
|
1915
1996
|
assistantMessageInterface: "macos",
|
|
1916
|
-
voiceControlPrompt: activeTurn
|
|
1917
|
-
? `${LIVE_VOICE_CONTROL_PROMPT}\n\n${buildInterruptionMergeNote(
|
|
1918
|
-
activeTurn.interruptedRequest,
|
|
1919
|
-
)}`
|
|
1920
|
-
: LIVE_VOICE_CONTROL_PROMPT,
|
|
1997
|
+
voiceControlPrompt: buildVoiceControlPrompt(activeTurn),
|
|
1921
1998
|
content: leg.content,
|
|
1922
1999
|
isInbound: true,
|
|
1923
2000
|
signal: activeTurn.abortController.signal,
|
|
@@ -2586,6 +2663,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
2586
2663
|
// finalizes through finalizeAssistantTurn, not here, so this never clears a
|
|
2587
2664
|
// request that the barge-in follow-up turn is still about to consume.
|
|
2588
2665
|
this.pendingInterruptedRequest = null;
|
|
2666
|
+
// `pendingContinuationResult` is deliberately NOT cleared here. Unlike the
|
|
2667
|
+
// merge context (bound to the immediate barge-in follow-up), a completed
|
|
2668
|
+
// continuation's answer targets the next REAL turn, and hands-free server-VAD
|
|
2669
|
+
// routinely discards noise/empty-transcript utterances between turns; wiping
|
|
2670
|
+
// it on each such discard would frequently drop a valid result before the
|
|
2671
|
+
// user's next question. It is bounded instead by consume-once, the hard-stop
|
|
2672
|
+
// clear (abortDetachedRuns), and the model's "use only if relevant" framing.
|
|
2589
2673
|
utterance.completed = true;
|
|
2590
2674
|
const turnId = utterance.turnId;
|
|
2591
2675
|
if (!turnId) {
|
|
@@ -2914,21 +2998,22 @@ export function createLiveVoiceSession(
|
|
|
2914
2998
|
// (which include the interrupted turn's completed tool calls after teardown),
|
|
2915
2999
|
// so it resumes without repeating them. Uses spawnAndAwait (synchronous mode)
|
|
2916
3000
|
// so the terminal parent-notification is skipped — the continuation stays
|
|
2917
|
-
// silent
|
|
2918
|
-
// `signal` aborts the child
|
|
3001
|
+
// silent (never spoken unprompted); its final answer is RETURNED so the session
|
|
3002
|
+
// can fold it into the next turn the user starts. The `signal` aborts the child
|
|
3003
|
+
// on a stop/interrupt (spawnAndAwait then rejects).
|
|
2919
3004
|
async function defaultSpawnBackgroundContinuation(args: {
|
|
2920
3005
|
parentConversationId: string;
|
|
2921
3006
|
objective: string;
|
|
2922
3007
|
label: string;
|
|
2923
3008
|
signal: AbortSignal;
|
|
2924
|
-
}): Promise<
|
|
3009
|
+
}): Promise<string> {
|
|
2925
3010
|
const parentConversation = findConversation(args.parentConversationId);
|
|
2926
3011
|
if (!parentConversation) {
|
|
2927
3012
|
throw new Error(
|
|
2928
3013
|
`Cannot detach interrupted voice turn: conversation ${args.parentConversationId} is not resident.`,
|
|
2929
3014
|
);
|
|
2930
3015
|
}
|
|
2931
|
-
await getSubagentManager().spawnAndAwait(
|
|
3016
|
+
return await getSubagentManager().spawnAndAwait(
|
|
2932
3017
|
{
|
|
2933
3018
|
parentConversationId: args.parentConversationId,
|
|
2934
3019
|
label: args.label,
|
|
@@ -2938,7 +3023,8 @@ async function defaultSpawnBackgroundContinuation(args: {
|
|
|
2938
3023
|
parentMessages: [...parentConversation.messages],
|
|
2939
3024
|
parentSystemPrompt: parentConversation.getCurrentSystemPrompt(),
|
|
2940
3025
|
},
|
|
2941
|
-
// No client-facing events: the continuation is silent
|
|
3026
|
+
// No client-facing events: the continuation is silent; its result is folded
|
|
3027
|
+
// into the next user turn as context, never spoken on its own.
|
|
2942
3028
|
() => {},
|
|
2943
3029
|
{ signal: args.signal },
|
|
2944
3030
|
);
|
|
@@ -236,17 +236,6 @@ export const messageMetadataSchema = z
|
|
|
236
236
|
* treat message text as organic user input.
|
|
237
237
|
*/
|
|
238
238
|
hidden: z.boolean().optional(),
|
|
239
|
-
/**
|
|
240
|
-
* How the send was initiated when the user message was sent on the
|
|
241
|
-
* user's behalf by the UI rather than typed by hand (`body.source` on
|
|
242
|
-
* `POST /v1/messages`). Current value: `"nav_redirect"` — a navigation
|
|
243
|
-
* shortcut redirected the user into chat with a pre-filled message.
|
|
244
|
-
* Read back by turn telemetry (`turn-events-store`) so analytics can
|
|
245
|
-
* separate UI-initiated turns from hand-typed ones, and by the queue
|
|
246
|
-
* drain to steer the turn. Kept as a plain string so new sources never
|
|
247
|
-
* fail metadata validation. Absent on hand-typed messages.
|
|
248
|
-
*/
|
|
249
|
-
userMessageSource: z.string().optional(),
|
|
250
239
|
/**
|
|
251
240
|
* Discriminates daemon-authored rows from ordinary turns.
|
|
252
241
|
* `"system_card"` marks pre-composed status cards (the /compact, /clean,
|
|
@@ -310,19 +299,6 @@ export function isHiddenMessageMetadata(
|
|
|
310
299
|
return metadata?.hidden === true;
|
|
311
300
|
}
|
|
312
301
|
|
|
313
|
-
/**
|
|
314
|
-
* Shared accessor for the send-source tag on user-message metadata (see the
|
|
315
|
-
* `userMessageSource` field on {@link messageMetadataSchema}). Returns the
|
|
316
|
-
* tag (e.g. `"nav_redirect"`) or undefined when the message was typed by
|
|
317
|
-
* hand or predates the field.
|
|
318
|
-
*/
|
|
319
|
-
export function getUserMessageSourceMetadata(
|
|
320
|
-
metadata: Record<string, unknown> | null | undefined,
|
|
321
|
-
): string | undefined {
|
|
322
|
-
const source = metadata?.userMessageSource;
|
|
323
|
-
return typeof source === "string" && source.length > 0 ? source : undefined;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
302
|
/**
|
|
327
303
|
* `messageKind` value marking a daemon-authored system card — a pre-composed
|
|
328
304
|
* status reply (the /compact, /clean, and summarize-up-to result cards) that
|
|
@@ -787,8 +787,19 @@ describe("streamCommitImport — memory ceiling", () => {
|
|
|
787
787
|
// MB archive, RSS would spike by at least 100 MB; a streaming
|
|
788
788
|
// importer's per-entry working set is bounded by ~one tar entry's
|
|
789
789
|
// internal buffers (a few MB).
|
|
790
|
-
|
|
791
|
-
|
|
790
|
+
//
|
|
791
|
+
// Bun 1.3.11 can throw `SystemError: Failed to get memory usage` from the
|
|
792
|
+
// underlying syscall; guard every sample so a spurious failure doesn't
|
|
793
|
+
// flake the run — a skipped sample just keeps the prior peak.
|
|
794
|
+
const sampleRss = (): number | null => {
|
|
795
|
+
try {
|
|
796
|
+
return process.memoryUsage().rss;
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
const baselineRss = sampleRss();
|
|
802
|
+
let peakRss = baselineRss ?? 0;
|
|
792
803
|
let progressCount = 0;
|
|
793
804
|
|
|
794
805
|
const result = await streamCommitImport({
|
|
@@ -797,8 +808,8 @@ describe("streamCommitImport — memory ceiling", () => {
|
|
|
797
808
|
workspaceDir,
|
|
798
809
|
onProgress: () => {
|
|
799
810
|
progressCount += 1;
|
|
800
|
-
const cur =
|
|
801
|
-
if (cur > peakRss) peakRss = cur;
|
|
811
|
+
const cur = sampleRss();
|
|
812
|
+
if (cur !== null && cur > peakRss) peakRss = cur;
|
|
802
813
|
},
|
|
803
814
|
});
|
|
804
815
|
|
|
@@ -810,7 +821,7 @@ describe("streamCommitImport — memory ceiling", () => {
|
|
|
810
821
|
// The 64 MB delta bound is a rough guard proving "it doesn't buffer
|
|
811
822
|
// the whole bundle" — if the importer were accumulating the 100 MB
|
|
812
823
|
// archive in memory, RSS would jump well past this threshold.
|
|
813
|
-
const delta = peakRss - baselineRss;
|
|
824
|
+
const delta = peakRss - (baselineRss ?? 0);
|
|
814
825
|
expect(delta).toBeLessThan(64 * 1024 * 1024);
|
|
815
826
|
} finally {
|
|
816
827
|
try {
|
|
@@ -1372,11 +1372,6 @@ export async function handleSendMessage(
|
|
|
1372
1372
|
// handoff to prime a proactive assistant greeting without showing the
|
|
1373
1373
|
// triggering user message. Honored on the standard send path only.
|
|
1374
1374
|
hidden?: boolean;
|
|
1375
|
-
// How the send was initiated when it wasn't typed by hand — e.g.
|
|
1376
|
-
// "nav_redirect" for UI shortcuts that send on the user's behalf.
|
|
1377
|
-
// Persisted as `metadata.userMessageSource` (analytics attribution) and
|
|
1378
|
-
// threaded to the agent loop as `messageSource` (per-turn steering).
|
|
1379
|
-
source?: string;
|
|
1380
1375
|
bypassSecretCheck?: boolean;
|
|
1381
1376
|
hostHomeDir?: string;
|
|
1382
1377
|
hostUsername?: string;
|
|
@@ -1420,12 +1415,6 @@ export async function handleSendMessage(
|
|
|
1420
1415
|
: undefined;
|
|
1421
1416
|
const clientMessageId =
|
|
1422
1417
|
typeof body.clientMessageId === "string" ? body.clientMessageId : undefined;
|
|
1423
|
-
// Normalized send-source tag ("nav_redirect", ...). The body is not
|
|
1424
|
-
// runtime-validated, so guard the type before persisting/threading it.
|
|
1425
|
-
const messageSource =
|
|
1426
|
-
typeof body.source === "string" && body.source.length > 0
|
|
1427
|
-
? body.source
|
|
1428
|
-
: undefined;
|
|
1429
1418
|
const requestedInferenceProfile =
|
|
1430
1419
|
typeof body.inferenceProfile === "string"
|
|
1431
1420
|
? body.inferenceProfile
|
|
@@ -2085,10 +2074,6 @@ export async function handleSendMessage(
|
|
|
2085
2074
|
// hidden send that lands mid-turn stays hidden when drained —
|
|
2086
2075
|
// the drain path persists this metadata and skips the echo.
|
|
2087
2076
|
...(body.hidden === true ? { hidden: true } : {}),
|
|
2088
|
-
// Carry the send-source tag through the queue the same way — the
|
|
2089
|
-
// drain path persists this metadata and reads it back for the
|
|
2090
|
-
// turn's `messageSource` steering.
|
|
2091
|
-
...(messageSource ? { userMessageSource: messageSource } : {}),
|
|
2092
2077
|
},
|
|
2093
2078
|
clientMetadata,
|
|
2094
2079
|
),
|
|
@@ -2491,11 +2476,10 @@ export async function handleSendMessage(
|
|
|
2491
2476
|
attachments,
|
|
2492
2477
|
requestId,
|
|
2493
2478
|
metadata: withClientMetadata(
|
|
2494
|
-
body.automated === true || body.hidden === true
|
|
2479
|
+
body.automated === true || body.hidden === true
|
|
2495
2480
|
? {
|
|
2496
2481
|
...(body.automated === true ? { automated: true } : {}),
|
|
2497
2482
|
...(body.hidden === true ? { hidden: true } : {}),
|
|
2498
|
-
...(messageSource ? { userMessageSource: messageSource } : {}),
|
|
2499
2483
|
}
|
|
2500
2484
|
: undefined,
|
|
2501
2485
|
clientMetadata,
|
|
@@ -2545,7 +2529,6 @@ export async function handleSendMessage(
|
|
|
2545
2529
|
isInteractive,
|
|
2546
2530
|
isUserMessage: true,
|
|
2547
2531
|
...(body.hidden === true ? { isHiddenPrompt: true } : {}),
|
|
2548
|
-
...(messageSource ? { messageSource } : {}),
|
|
2549
2532
|
})
|
|
2550
2533
|
.catch((err) => {
|
|
2551
2534
|
log.error(
|
|
@@ -3022,12 +3005,6 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
3022
3005
|
.describe(
|
|
3023
3006
|
"When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used for machine signals the user never typed (proactive-greeting priming, channel-setup wizard close). Suppression covers the queued path too: a hidden send that lands mid-turn returns { queued: true, requestId } but never appears in list-messages queued snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send path only — slash-command content bypasses it.",
|
|
3024
3007
|
),
|
|
3025
|
-
source: z
|
|
3026
|
-
.string()
|
|
3027
|
-
.optional()
|
|
3028
|
-
.describe(
|
|
3029
|
-
'How the send was initiated when the message was sent on the user\'s behalf by the UI rather than typed by hand. Current value: "nav_redirect" (a navigation shortcut redirected the user into chat with a pre-filled message). Persisted onto the message metadata for analytics attribution and used to steer the assistant toward a brief, concrete reply. Omit for messages the user typed.',
|
|
3030
|
-
),
|
|
3031
3008
|
onboarding: z
|
|
3032
3009
|
.object({
|
|
3033
3010
|
tools: z.array(z.string()),
|
|
@@ -33,16 +33,37 @@ interface MemoryInfo {
|
|
|
33
33
|
maxMb: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Sample this process's resident set size (RSS) in bytes, returning `null` when
|
|
38
|
+
* the reading fails.
|
|
39
|
+
*
|
|
40
|
+
* Bun 1.3.11 can throw `SystemError: Failed to get memory usage, errno: 2` from
|
|
41
|
+
* the underlying getrusage syscall. The health payload is best-effort, so a
|
|
42
|
+
* failed sample must never propagate and turn the liveness probe into a 500 —
|
|
43
|
+
* fall back to `null` and let the caller substitute an alternative source.
|
|
44
|
+
*/
|
|
45
|
+
function sampleProcessRssBytes(): number | null {
|
|
46
|
+
try {
|
|
47
|
+
return process.memoryUsage().rss;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function getMemoryInfo(): MemoryInfo {
|
|
37
54
|
const bytesToMb = (b: number) => Math.round((b / (1024 * 1024)) * 100) / 100;
|
|
38
55
|
// In platform-managed mode the daemon shares its Node process with whatever
|
|
39
56
|
// the container is doing as a whole; `process.memoryUsage().rss` only sees
|
|
40
57
|
// this process's resident set, which understates the container footprint
|
|
41
58
|
// operators care about. Read the cgroup usage file directly so /v1/health
|
|
42
|
-
// matches what the StatefulSet's memory limit is enforced against.
|
|
59
|
+
// matches what the StatefulSet's memory limit is enforced against. When RSS
|
|
60
|
+
// can't be sampled, fall back to the cgroup usage (if any) before reporting 0
|
|
61
|
+
// — the metric is best-effort and must never fail the probe.
|
|
43
62
|
const currentBytes =
|
|
44
63
|
(getIsPlatform() ? getContainerMemoryUsageBytes() : null) ??
|
|
45
|
-
|
|
64
|
+
sampleProcessRssBytes() ??
|
|
65
|
+
getContainerMemoryUsageBytes() ??
|
|
66
|
+
0;
|
|
46
67
|
return {
|
|
47
68
|
currentMb: bytesToMb(currentBytes),
|
|
48
69
|
maxMb: bytesToMb(getContainerMemoryLimitBytes() ?? totalmem()),
|
|
@@ -55,21 +55,6 @@ export interface TurnEvent {
|
|
|
55
55
|
* and should never appear on a user-message row.
|
|
56
56
|
*/
|
|
57
57
|
channelId: string | null;
|
|
58
|
-
/**
|
|
59
|
-
* Send-source tag for messages the UI sent on the user's behalf
|
|
60
|
-
* (`"nav_redirect"` — a navigation shortcut redirected the user into
|
|
61
|
-
* chat with a pre-filled message). Sourced from
|
|
62
|
-
* `messages.metadata.userMessageSource`, stamped by `handleSendMessage`
|
|
63
|
-
* when the request body carries `source`. Null for hand-typed messages
|
|
64
|
-
* and rows predating the field. Lets activation-funnel analytics
|
|
65
|
-
* separate UI-initiated turns from organic ones.
|
|
66
|
-
*
|
|
67
|
-
* Daemon-side projection only for now: the platform's turn serializer
|
|
68
|
-
* does not yet accept a `source` wire field, so `turnSource` must not
|
|
69
|
-
* emit it until the platform serializer lands and the wire sync PR
|
|
70
|
-
* updates `telemetry-wire.generated.ts` (see `src/telemetry/AGENTS.md`).
|
|
71
|
-
*/
|
|
72
|
-
source: string | null;
|
|
73
58
|
/**
|
|
74
59
|
* Flexible client metadata stashed under `messages.metadata.client` by
|
|
75
60
|
* the HTTP header middleware. Carries optional `browserFamily`,
|
|
@@ -175,11 +160,6 @@ export function queryUnreportedTurnEvents(
|
|
|
175
160
|
>`json_extract(${messages.metadata}, '$.userMessageChannel')`.as(
|
|
176
161
|
"channel_id",
|
|
177
162
|
),
|
|
178
|
-
source: sql<
|
|
179
|
-
string | null
|
|
180
|
-
>`json_extract(${messages.metadata}, '$.userMessageSource')`.as(
|
|
181
|
-
"source",
|
|
182
|
-
),
|
|
183
163
|
clientMetadata: sql<
|
|
184
164
|
string | null
|
|
185
165
|
>`json_extract(${messages.metadata}, '$.client')`.as("client_metadata"),
|