@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.10-dev.202607171631.a0a52fd",
3
+ "version": "0.10.10-dev.202607171828.fc2f5d5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- let runMessages = finalUserPromptCtx.latestMessages;
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
@@ -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<void>((resolve) => {
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 is silent
136
- // (no parent notification) until the resurface flow lands, and aborts when
137
- // `signal` fires. Injected for testability; the factory wires the real
138
- // SubagentManager-backed implementation.
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<void>;
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 for this turn (one barge-in
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
- await this.launchAssistantTurn(utterance, content, { interruptedRequest });
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.interruptedRequest
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 until the resurface flow lands, and this call ignores its result. The
2918
- // `signal` aborts the child on a stop/interrupt.
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<void> {
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 until resurface.
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
- const baselineRss = process.memoryUsage().rss;
791
- let peakRss = baselineRss;
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 = process.memoryUsage().rss;
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 || messageSource
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
- process.memoryUsage().rss;
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"),