@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/docs/guardian-request-flow.md +35 -0
  7. package/knip.json +3 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  10. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  11. package/openapi.yaml +136 -76
  12. package/package.json +1 -1
  13. package/scripts/write-plugin-api-shim.ts +10 -0
  14. package/src/__tests__/app-control-flow.test.ts +1 -0
  15. package/src/__tests__/approval-routes-http.test.ts +2 -2
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  17. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  18. package/src/__tests__/compaction-events.test.ts +8 -10
  19. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  21. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  22. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  23. package/src/__tests__/conversation-queue.test.ts +39 -62
  24. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  27. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  28. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
  30. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  31. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  37. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  38. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  39. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  40. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  41. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  42. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  43. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  44. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  45. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  46. package/src/__tests__/document-sync-tags.test.ts +0 -75
  47. package/src/__tests__/file-ops-service.test.ts +163 -30
  48. package/src/__tests__/filesystem-tools.test.ts +23 -24
  49. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  50. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  51. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  52. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  53. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  54. package/src/__tests__/media-generate-image.test.ts +131 -21
  55. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  56. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  57. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  58. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  59. package/src/__tests__/qdrant-manager.test.ts +14 -1
  60. package/src/__tests__/run-due-schedules.test.ts +21 -0
  61. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  62. package/src/__tests__/schedule-routes.test.ts +23 -0
  63. package/src/__tests__/schedule-store.test.ts +17 -0
  64. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  65. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  66. package/src/__tests__/starter-task-flow.test.ts +5 -4
  67. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  68. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  69. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  70. package/src/__tests__/subagent-tools.test.ts +81 -101
  71. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  72. package/src/__tests__/tool-executor.test.ts +5 -1
  73. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  74. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  75. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  76. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  77. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  78. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  79. package/src/acp/session-manager.ts +8 -1
  80. package/src/api/events/host-file.ts +2 -2
  81. package/src/api/surfaces.ts +5 -0
  82. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  83. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  84. package/src/calls/voice-session-bridge.ts +44 -25
  85. package/src/calls/voice-triage-escalate.ts +1 -0
  86. package/src/cli/bundled-modules.ts +29 -0
  87. package/src/cli/commands/db/repair.ts +4 -8
  88. package/src/cli/commands/domain.ts +6 -3
  89. package/src/cli/commands/email.ts +6 -3
  90. package/src/cli/commands/keys.ts +8 -3
  91. package/src/cli/commands/plugins.ts +85 -36
  92. package/src/cli/commands/schedules.ts +35 -1
  93. package/src/cli/lib/bundled-marketplace.json +1 -1
  94. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  95. package/src/config/assistant-feature-flags.ts +36 -15
  96. package/src/config/balanced-model-experiment.ts +35 -0
  97. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  98. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  99. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  100. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  101. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  102. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  103. package/src/config/call-site-defaults.ts +7 -0
  104. package/src/config/default-profile-catalog.ts +96 -4
  105. package/src/config/feature-flag-registry.json +11 -11
  106. package/src/config/skills.ts +9 -2
  107. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  108. package/src/daemon/conversation-agent-loop.ts +14 -13
  109. package/src/daemon/conversation-notifiers.ts +11 -9
  110. package/src/daemon/conversation-process.ts +0 -27
  111. package/src/daemon/conversation-runtime-assembly.ts +9 -2
  112. package/src/daemon/conversation-store.ts +4 -4
  113. package/src/daemon/conversation-surfaces.ts +27 -13
  114. package/src/daemon/conversation-tool-setup.ts +2 -4
  115. package/src/daemon/conversation.ts +104 -51
  116. package/src/daemon/doordash-steps.ts +2 -2
  117. package/src/daemon/lifecycle.ts +14 -1
  118. package/src/daemon/process-message.ts +0 -13
  119. package/src/daemon/windows-compiled-entry.ts +4 -0
  120. package/src/documents/document-store.ts +5 -235
  121. package/src/hooks/types.ts +5 -0
  122. package/src/ipc/gateway-flag-listener.ts +17 -3
  123. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  124. package/src/live-voice/live-voice-manager.ts +16 -3
  125. package/src/live-voice/live-voice-session.ts +63 -9
  126. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  127. package/src/monitoring/control.ts +1 -0
  128. package/src/monitoring/db-integrity-sample.ts +4 -5
  129. package/src/notifications/AGENTS.md +2 -0
  130. package/src/notifications/approval-card-data.ts +33 -0
  131. package/src/permissions/prompter.ts +1 -5
  132. package/src/persistence/conversation-queries.ts +66 -16
  133. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  134. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  135. package/src/persistence/schema/documents.ts +4 -4
  136. package/src/plugin-api/constants.ts +8 -0
  137. package/src/plugin-api/index.ts +5 -1
  138. package/src/plugin-api/webhook-url.ts +13 -11
  139. package/src/plugins/defaults/main.ts +6 -7
  140. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  141. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  142. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  143. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  144. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  145. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  146. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  147. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  148. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  149. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  150. package/src/plugins/mtime-cache.ts +17 -0
  151. package/src/prompts/templates/system-sections.ts +0 -7
  152. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  153. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  154. package/src/providers/openai/chat-completions-provider.ts +11 -1
  155. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  156. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  157. package/src/routes/control.ts +1 -0
  158. package/src/routes/route-host-client.ts +1 -0
  159. package/src/runtime/AGENTS.md +16 -17
  160. package/src/runtime/agent-wake.ts +15 -12
  161. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  162. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  163. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  164. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  165. package/src/runtime/routes/conversation-routes.ts +11 -13
  166. package/src/runtime/routes/documents-routes.ts +3 -222
  167. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  168. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  169. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  170. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  171. package/src/runtime/routes/schedule-routes.ts +94 -7
  172. package/src/runtime/routes/workspace-routes.ts +0 -9
  173. package/src/runtime/routes/workspace-utils.ts +3 -13
  174. package/src/runtime/services/conversation-serializer.ts +7 -2
  175. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  176. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  177. package/src/schedule/plugin-schedule-availability.ts +58 -0
  178. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  179. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  180. package/src/schedule/schedule-store.ts +5 -1
  181. package/src/schedule/scheduler.ts +9 -4
  182. package/src/schedule/worker-control.ts +1 -0
  183. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  184. package/src/subagent/consult-context.ts +11 -11
  185. package/src/subagent/consult-prompt.ts +26 -35
  186. package/src/subagent/manager.ts +20 -37
  187. package/src/subagent/notify.ts +7 -1
  188. package/src/subagent/types.ts +15 -13
  189. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  190. package/src/tools/acp/spawn.ts +6 -4
  191. package/src/tools/filesystem/read.ts +27 -10
  192. package/src/tools/host-filesystem/read.ts +27 -15
  193. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  194. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  195. package/src/tools/shared/filesystem/types.ts +5 -5
  196. package/src/tools/skills/scaffold-managed.ts +25 -7
  197. package/src/tools/subagent/spawn.ts +28 -88
  198. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  199. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  200. package/src/util/logger.ts +16 -0
  201. package/src/util/worker-process.ts +37 -4
  202. package/src/windows-compiled-cli.ts +32 -0
  203. package/src/windows-compiled-entry.ts +4 -0
  204. package/src/windows-compiled-logger.ts +6 -0
  205. package/src/windows-compiled-worker-entry.ts +29 -0
  206. package/src/__tests__/document-workspace-file.test.ts +0 -467
  207. package/src/daemon/interactive-turn-sender.ts +0 -59
  208. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  209. package/src/subagent/consult-transcript.ts +0 -90
@@ -239,6 +239,30 @@ describe("detectOpenAICompatibleContextOverflow", () => {
239
239
  expect(detectOpenAICompatibleContextOverflow(err)).toBeNull();
240
240
  });
241
241
 
242
+ test("matches OpenAI's per-part 10MiB string_above_max_length 400", () => {
243
+ const err = buildOpenAIApiError(400, {
244
+ message:
245
+ "Invalid 'input[191].content[1].text': string too long. Expected a string with maximum length 10485760, but got a string with length 11436754 instead.",
246
+ type: "invalid_request_error",
247
+ code: "string_above_max_length",
248
+ });
249
+ const out = detectOpenAICompatibleContextOverflow(err);
250
+ expect(out).not.toBeNull();
251
+ // Byte lengths in this message must not be misread as token counts.
252
+ expect(out?.actualTokens).toBeUndefined();
253
+ expect(out?.maxTokens).toBeUndefined();
254
+ });
255
+
256
+ test("returns null for string_above_max_length on a non-content field", () => {
257
+ const err = buildOpenAIApiError(400, {
258
+ message:
259
+ "Invalid 'tools[0].function.description': string too long. Expected a string with maximum length 1048576, but got a string with length 2000000 instead.",
260
+ type: "invalid_request_error",
261
+ code: "string_above_max_length",
262
+ });
263
+ expect(detectOpenAICompatibleContextOverflow(err)).toBeNull();
264
+ });
265
+
242
266
  test("matches 'too many input tokens' variant emitted by some OpenAI-compatible providers", () => {
243
267
  const err = buildOpenAIApiError(400, {
244
268
  message: "too many input tokens: 250000",
@@ -370,6 +370,26 @@ describe("RetryProvider — callSite resolution", () => {
370
370
  }
371
371
  });
372
372
 
373
+ test("memory-v3 selection does not inherit high effort", async () => {
374
+ setLlmConfig({ defaultProvider: { provider: "anthropic" } });
375
+
376
+ let seen: SendMessageOptions | undefined;
377
+ const wrapped = new RetryProvider(
378
+ makeProvider("anthropic", (options) => {
379
+ seen = options;
380
+ }),
381
+ );
382
+
383
+ await wrapped.sendMessage(DUMMY_MESSAGES, {
384
+ config: { callSite: "memoryV3SelectL2" },
385
+ });
386
+
387
+ const config = seen?.config as Record<string, unknown>;
388
+ expect(config.effort).toBe("low");
389
+ expect(config.thinking).toEqual({ type: "disabled" });
390
+ expect(config.temperature).toBe(0);
391
+ });
392
+
373
393
  test("propagates resolved effort/speed/temperature; omits server-side fields", async () => {
374
394
  setLlmConfig({
375
395
  callSites: {
@@ -85,7 +85,17 @@ export function detectOpenAICompatibleContextOverflow(
85
85
  /context.?length.?exceeded|context.?window.?exceeded|prompt.?is.?too.?long|prompt_too_long|input.?too.?long|too.?many.?(?:input.?)?tokens|maximum.?context/i.test(
86
86
  message,
87
87
  );
88
- if (!codeMatches && !messageMatches) {
88
+ // string_above_max_length is OpenAI's generic oversized-string validation
89
+ // code, so only treat it as overflow when the error points at a message
90
+ // content part (e.g. "Invalid 'input[191].content[1].text': string too
91
+ // long" — OpenAI's per-part 10 MiB cap). The overflow ladder can shrink
92
+ // message content (media stubbing collapses a file's extracted_text to a
93
+ // preview) but cannot fix other oversized fields like tool definitions.
94
+ const oversizedContentPart =
95
+ /string.?too.?long|string_above_max_length/i.test(
96
+ `${code ?? ""} ${message}`,
97
+ ) && /\b(?:input|messages)\[\d+\]\.content/i.test(message);
98
+ if (!codeMatches && !messageMatches && !oversizedContentPart) {
89
99
  return null;
90
100
  }
91
101
  // OpenAI-compatible providers rarely report usable token counts; best-effort extract.
@@ -185,7 +185,6 @@ describe("DeepgramFluxRealtimeTranscriber", () => {
185
185
  const transcriber = new DeepgramFluxRealtimeTranscriber(TEST_API_KEY, {
186
186
  // Long enough that no watchdog fires mid-test.
187
187
  inactivityTimeoutMs: 60_000,
188
- keepaliveIntervalMs: 0,
189
188
  ...options,
190
189
  });
191
190
  const events: SttStreamServerEvent[] = [];
@@ -571,16 +570,22 @@ describe("DeepgramFluxRealtimeTranscriber", () => {
571
570
  expect(events).toEqual([{ type: "closed" }]);
572
571
  });
573
572
 
574
- test("keepalive frames go out on the configured interval", async () => {
575
- const { transcriber } = await startSession({ keepaliveIntervalMs: 10 });
573
+ test("no KeepAlive is ever sent: Flux rejects it and closes", async () => {
574
+ const { transcriber } = await startSession();
576
575
 
576
+ // Flux accepts only CloseStream and Configure. A KeepAlive earns an
577
+ // UNPARSABLE_CLIENT_MESSAGE error frame and a server close, which on a
578
+ // stream held across turns kills it every keepalive interval.
577
579
  await Bun.sleep(35);
578
580
  transcriber.stop();
579
581
 
580
- const keepalives = mockWs.sentData.filter(
581
- (data) => data === JSON.stringify({ type: "KeepAlive" }),
582
+ const controlFrames = mockWs.sentData.filter(
583
+ (data) => typeof data === "string",
584
+ );
585
+ expect(controlFrames).not.toContain(
586
+ JSON.stringify({ type: "KeepAlive" }),
582
587
  );
583
- expect(keepalives.length).toBeGreaterThanOrEqual(2);
588
+ expect(controlFrames).toContain(JSON.stringify({ type: "CloseStream" }));
584
589
  });
585
590
 
586
591
  test("finalizeUtterance is absent, Flux has no mid-stream flush", async () => {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Flux is Deepgram's conversational speech API: the model itself decides where
5
5
  * a turn ends, so this adapter carries no endpointing heuristics of its own.
6
- * It owns the socket lifecycle (connect, keepalive, teardown) and delegates
6
+ * It owns the socket lifecycle (connect, teardown) and delegates
7
7
  * every inbound transcript frame to {@link parseFluxFrame}, the pure protocol
8
8
  * module, which maps Flux's wire shapes onto the daemon's
9
9
  * {@link SttStreamServerEvent} contract.
@@ -24,6 +24,14 @@
24
24
  * a caller-side boundary, so the optional method is left off and callers
25
25
  * feature-detect it and fall back to {@link stop}.
26
26
  *
27
+ * There is also **no keepalive**. Flux accepts exactly two control messages,
28
+ * `CloseStream` and `Configure`; the v1 streaming `KeepAlive` is not one of
29
+ * them, and sending it earns an `UNPARSABLE_CLIENT_MESSAGE` error frame
30
+ * followed by a server close. Audio is the only thing that holds a Flux stream
31
+ * open, so a stream that has to survive a long silence must carry silent
32
+ * frames rather than a control message.
33
+ * See https://developers.deepgram.com/docs/flux/close-stream
34
+ *
27
35
  * Error handling mirrors `deepgram-realtime.ts`: socket closes and errors map
28
36
  * onto {@link SttErrorCategory} values (`auth`, `rate-limit`, `timeout`,
29
37
  * `provider-error`), in-session failures surface as `error` events, and
@@ -69,13 +77,6 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
69
77
  */
70
78
  const DEFAULT_INACTIVITY_TIMEOUT_MS = 30_000;
71
79
 
72
- /**
73
- * Interval (ms) between `KeepAlive` control frames. Deepgram closes a socket
74
- * that carries no audio for ~10s, and raw silence does not reset that timer.
75
- * Only the explicit control message does.
76
- */
77
- const DEFAULT_KEEPALIVE_INTERVAL_MS = 5_000;
78
-
79
80
  /** Outbound buffer ceiling (bytes) before {@link sendAudio} drops frames. */
80
81
  const MAX_BUFFERED_AMOUNT = 1024 * 1024; // 1 MiB
81
82
 
@@ -114,11 +115,6 @@ export interface DeepgramFluxRealtimeOptions {
114
115
  connectTimeoutMs?: number;
115
116
  /** Inactivity timeout in milliseconds. Default: 30_000. */
116
117
  inactivityTimeoutMs?: number;
117
- /**
118
- * Interval (ms) between `KeepAlive` control frames. Default: 5_000. Set to
119
- * 0 to disable (tests only, because Deepgram closes silent sockets after ~10s).
120
- */
121
- keepaliveIntervalMs?: number;
122
118
  }
123
119
 
124
120
  // ---------------------------------------------------------------------------
@@ -169,7 +165,6 @@ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
169
165
  private readonly sampleRate: number;
170
166
  private readonly connectTimeoutMs: number;
171
167
  private readonly inactivityTimeoutMs: number;
172
- private readonly keepaliveIntervalMs: number;
173
168
 
174
169
  /** The live WebSocket connection, set during start(). */
175
170
  private ws: WsLike | null = null;
@@ -208,9 +203,6 @@ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
208
203
  /** Close grace timer handle. */
209
204
  private closeGraceTimer: ReturnType<typeof setTimeout> | null = null;
210
205
 
211
- /** Periodic `KeepAlive` timer. */
212
- private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
213
-
214
206
  constructor(apiKey: string, options: DeepgramFluxRealtimeOptions = {}) {
215
207
  this.apiKey = apiKey;
216
208
  this.flux = getConfig().liveVoice.flux;
@@ -219,8 +211,6 @@ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
219
211
  options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
220
212
  this.inactivityTimeoutMs =
221
213
  options.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
222
- this.keepaliveIntervalMs =
223
- options.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
224
214
  }
225
215
 
226
216
  // ── StreamingTranscriber interface ──────────────────────────────────
@@ -300,7 +290,6 @@ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
300
290
  // Socket is open. Attach the handlers for the active session lifetime.
301
291
  this.attachSessionHandlers(ws);
302
292
  this.resetInactivityTimer();
303
- this.startKeepaliveTimer();
304
293
 
305
294
  log.info({ model: this.flux.model }, "Deepgram Flux session opened");
306
295
  }
@@ -586,35 +575,6 @@ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
586
575
  clearTimeout(this.closeGraceTimer);
587
576
  this.closeGraceTimer = null;
588
577
  }
589
- if (this.keepaliveTimer !== null) {
590
- clearInterval(this.keepaliveTimer);
591
- this.keepaliveTimer = null;
592
- }
593
- }
594
-
595
- /**
596
- * Start the periodic keepalive. A `KeepAlive` control frame is the only
597
- * thing that resets Deepgram's server-side inactivity timer while the
598
- * stream carries silence. Raw silent PCM does not count.
599
- */
600
- private startKeepaliveTimer(): void {
601
- if (this.closed || this.stopping || this.keepaliveIntervalMs <= 0) {
602
- return;
603
- }
604
- this.keepaliveTimer = setInterval(() => {
605
- if (this.closed || this.stopping) {
606
- return;
607
- }
608
- const ws = this.ws;
609
- if (!ws || ws.readyState !== WS_OPEN) {
610
- return;
611
- }
612
- try {
613
- ws.send(JSON.stringify({ type: "KeepAlive" }));
614
- } catch (err) {
615
- log.warn({ err }, "Deepgram Flux KeepAlive send failed");
616
- }
617
- }, this.keepaliveIntervalMs);
618
578
  }
619
579
 
620
580
  /**
@@ -68,6 +68,7 @@ export async function spawnRouteHostWorkerProcess(
68
68
  return await spawnWorkerProcess({
69
69
  pidPath: routeHostPidPath(),
70
70
  entry: new URL("./worker.ts", import.meta.url),
71
+ packagedEntry: "routes",
71
72
  workerLabel: "Route host",
72
73
  options: opts,
73
74
  });
@@ -149,6 +149,7 @@ export class RouteHostClient {
149
149
  ({ pid } = await spawnWorkerProcess({
150
150
  pidPath: this.pidPath,
151
151
  entry: this.workerEntryUrl,
152
+ packagedEntry: "routes",
152
153
  workerLabel: "Route host",
153
154
  // Owned by the daemon (appears in its process tree, torn down with it);
154
155
  // kill it if it hangs during startup so a failed spawn leaves nothing.
@@ -93,39 +93,38 @@ Host app-control allows the assistant to proxy app-control actions (target a spe
93
93
  - `POST /v1/host-app-control-result` — `{ requestId, state, pngBase64?, windowBounds?, executionResult?, executionError? }`. `state` is one of `"running" | "missing" | "minimized"`.
94
94
  - **Tracking**: Uses the same `pending-interactions` tracker as the other host proxy types, with `kind: "host_app_control"`. The route handler is in `host-app-control-routes.ts` and forwards the payload to the owning conversation's `hostAppControlProxy.resolve()`. Late delivery is tolerated — the route returns 200 even when no pending interaction matches (e.g. the conversation was disposed before the client reported back).
95
95
 
96
- ### `chrome-extension` interface (Phase 2)
96
+ ### `chrome-extension` interface
97
97
 
98
98
  The `chrome-extension` interface in `INTERFACE_IDS` is a non-interactive transport that supports only the `host_browser` capability — it does NOT support `host_bash`, `host_file`, or `host_cu`. This is encoded in `supportsHostProxy(id, capability)`: passing a capability argument returns `true` for `chrome-extension` only when the capability is `host_browser`; the no-arg form returns `false` for `chrome-extension` (so legacy desktop-only call sites that assume full-desktop proxy availability continue to gate correctly).
99
99
 
100
- For **self-hosted** deployments, `host_browser_request` frames are routed through the `ChromeExtensionRegistry` singleton (`runtime/chrome-extension-registry.ts`), which tracks active chrome-extension WebSocket connections keyed by `(guardianId, clientInstanceId)`. The registry is populated on WebSocket `open` and drained on `close` inside `http-server.ts`'s `/v1/browser-relay` handlers — see the `wsType === "browser-relay"` branches. For **cloud/platform-hosted** deployments, the chrome extension connects via SSE (`GET /v1/events` with `X-Vellum-Interface-Id: chrome-extension`) and `host_browser_request` frames travel through `assistantEventHub` to the SSE stream. The extension POSTs results back to `POST /v1/host-browser-result`. Transport selection is handled by `HostBrowserProxy`, which publishes events to the `assistantEventHub` with `targetCapability: "host_browser"` — the hub delivers to whichever subscriber (chrome-extension or macOS client) has the `host_browser` capability. For macOS, `host_browser_request` frames travel through `assistantEventHub` (SSE) by default; when the guardian also has an active extension connection, the registry-routed WebSocket sender takes precedence.
100
+ The extension reaches the daemon over the same two doors every client uses. Self-hosted, it pairs once through the gateway's `POST /v1/pair` (`gateway/src/http/routes/pair.ts`: loopback-only, rate-limited, mints an `actor_client_v1` JWT for the `X-Vellum-Interface-Id: chrome-extension` caller); cloud deployments issue the guardian-bound JWT through the gateway's WorkOS-backed flow. It then subscribes to `GET /v1/events` with `X-Vellum-Interface-Id: chrome-extension`, which registers it on `assistantEventHub` with the single `host_browser` capability, and it answers work by POSTing to `POST /v1/host-browser-result`.
101
101
 
102
- A single guardian may have multiple parallel extension installs connected at once (two Chrome profiles, two desktops sharing a sync identity). Each install generates a stable `clientInstanceId` on first run, persists it in `chrome.storage.local`, and sends it on every WebSocket handshake as a query param (`clientInstanceId=...`) or header (`x-client-instance-id`). The registry keys inner entries by that id so sibling installs don't evict each other on register/unregister. The default `send(guardianId, msg)` path routes to whichever instance has the most recent activity (`lastActiveAt`); `sendToInstance(guardianId, clientInstanceId, msg)` pins a specific install. Older extension builds that omit the id get a connection-scoped `legacy:<connectionId>` fallback key so they degrade gracefully to single-instance semantics.
102
+ `HostBrowserProxy` (`daemon/host-browser-proxy.ts`, a lazily-created singleton) is the only sender: it publishes `host_browser_request` frames to the hub with `targetCapability: "host_browser"` and an explicit target client, choosing that client at send time from the hub's live roster (`resolveTargetClient`). Both the extension and the macOS desktop bridge register `host_browser`, so ordering is method-aware rather than pure recency: `Vellum.*` pseudo-methods (tabs, attach, detach) go only to a chrome-extension client, the only transport that implements them; raw CDP methods prefer a chrome-extension client over the macOS bridge, most recently active first within each group; an explicit `targetClientId` pins one client; and when a `sourceActorPrincipalId` is supplied only that actor's clients are eligible. Several extension installs for one guardian are simply several `host_browser` clients on the roster. A dispatch that lands during a brief MV3 service-worker reconnect waits up to `EXTENSION_RECONNECT_GRACE_MS` before failing.
103
103
 
104
- `Conversation.hostBrowserSenderOverride` is the integration point between the turn layer and the proxy. When any turn enters the routes layer and the guardian has an active extension connection in the `ChromeExtensionRegistry`, `conversation-routes.ts` resolves the registry entry and sets the override to a sender that writes to that WebSocket. This applies to chrome-extension turns (where the registry is the only transport) and macOS turns (where the extension connection lets browser tools route through the user's real Chrome session instead of cdp-inspect/local). `Conversation.restoreBrowserProxyAvailability()` re-threads the override on queue drain — without this, the drain path would clobber the registry-routed sender with the default `sendToClient` (pointed at the SSE hub) and `host_browser_request` frames would stop reaching the extension mid-queue.
104
+ Nothing in the turn layer wires the proxy: it reads the roster on every send, and the conversation's own event sink is fixed for its life (see `assistant/AGENTS.md`, "Conversation event delivery and turn presence"), so a queued or drained turn reaches the extension exactly like a live one.
105
105
 
106
- Capability token bootstrap for self-hosted deployments is handled by the gateway (`gateway/src/http/routes/browser-extension-pair.ts`) which mints a guardian-bound HMAC capability token. The daemon delegates token verification to the gateway via IPC (`verify_capability_token`) — it must never read secrets from `GATEWAY_SECURITY_DIR` or any other gateway-owned directory. Cloud deployments issue guardian-bound JWTs via the gateway's WorkOS-backed flow.
107
-
108
- See `docs/browser-use-architecture-phase2.md` for the full wire diagram and component inventory.
106
+ See `docs/browser-use-architecture-phase2.md` for the backend scenarios and the manual QA checklist.
109
107
 
110
108
  ### Canonical browser backend precedence (macOS)
111
109
 
112
110
  On macOS-originated turns, the CDP factory (`tools/browser/cdp-client/factory.ts`) evaluates three browser backends in strict priority order. Each candidate is tried lazily; if the first command fails with a transport-level error, the factory falls over to the next candidate. CDP protocol errors (the browser understood the command but rejected it) do NOT trigger failover.
113
111
 
114
- | Priority | Backend | Condition | Transport |
115
- | -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
116
- | 1 | **Extension / host proxy** | `hostBrowserProxy` present AND `isAvailable()` returns `true`. On macOS, the proxy is always provisioned. On other interfaces, requires an active hub subscriber with `host_browser` capability | WS via `ChromeExtensionRegistry` (self-hosted), SSE via `assistantEventHub` with `targetCapability: "host_browser"` (cloud extension or macOS) |
117
- | 2 | **cdp-inspect** | (a) `hostBrowser.cdpInspect.enabled` is `true` in config, OR (b) `transportInterface === "macos"` AND `desktopAuto.enabled` is `true` (default) AND the cooldown from a prior failure is not active | Direct CDP WebSocket to `localhost:9222` |
118
- | 3 | **Local** | Always present as the final fallback | In-process Playwright CDP via `browserManager` |
112
+ | Priority | Backend | Condition | Transport |
113
+ | -------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
114
+ | 1 | **Extension / host proxy** | Two candidates from the always-present `HostBrowserProxy` singleton: `extension` when `hasExtensionClient(actor)` finds a chrome-extension client on the hub; otherwise `host-bridge` when `isAvailable(actor)` finds any `host_browser` client (the macOS desktop bridge) and that actor's host-bridge cooldown is not active | SSE via `assistantEventHub` with `targetCapability: "host_browser"`, to the chosen client |
115
+ | 2 | **cdp-inspect** | (a) `hostBrowser.cdpInspect.enabled` is `true` in config, OR (b) `transportInterface === "macos"` AND `desktopAuto.enabled` is `true` (default) AND the cooldown from a prior failure is not active | Direct CDP WebSocket to `localhost:9222` |
116
+ | 3 | **Local** | Always present as the final fallback | In-process Playwright CDP via `browserManager` |
119
117
 
120
118
  **Transport selection for the extension/host-proxy backend:**
121
119
 
122
- The "extension" backend label is a misnomer inherited from the original Phase 2 design where only the Chrome Extension provided host-browser access. In the current architecture, two transports can power this backend:
120
+ The "extension" backend label predates the macOS bridge; two SSE transports power it, both through the same `HostBrowserProxy` → `ExtensionCdpClient` pipeline:
121
+
122
+ - **Chrome extension**: `HostBrowserProxy.send()` publishes to `assistantEventHub` with `targetCapability: "host_browser"`, targeting the chrome-extension client `resolveTargetClient` chose (see the interface section above); the extension executes the CDP command via `chrome.debugger` and POSTs the result to `/v1/host-browser-result`.
123
+ - **macOS SSE bridge**: when no chrome-extension client is eligible, the same publish targets the macOS subscriber (which registers every host-proxy capability); the desktop client executes the command against the local Chrome and POSTs the result the same way.
123
124
 
124
- - **Extension WebSocket** (self-hosted): When the `ChromeExtensionRegistry` has an active entry for the guardian, the registry-routed sender delivers frames over the `/v1/browser-relay` WebSocket to the Chrome extension, which executes CDP commands via `chrome.debugger`.
125
- - **Extension SSE** (cloud/platform): When no WebSocket entry exists, `HostBrowserProxy.send()` publishes to `assistantEventHub` with `targetCapability: "host_browser"`. The hub delivers the event to the chrome-extension subscriber (which registered with that capability via SSE). The extension POSTs results back to `/v1/host-browser-result`. This path is used for any `canServiceSseBrowser()` interface (`web`, `chrome-extension`, `macos`).
126
- - **macOS SSE bridge**: When the macOS desktop client is connected but no extension is present, the same hub publish with `targetCapability: "host_browser"` delivers to the macOS subscriber (which has all host-proxy capabilities). The desktop client executes CDP commands against the local Chrome and POSTs results back to `/v1/host-browser-result`.
125
+ In the CDP factory the bridge is the internal `"host-bridge"` candidate kind (`InternalBrowserMode`, never a caller-pinnable `browser_mode`). `browser_status` labels the extension path `details.transport: "extension-ws"`; the label predates the SSE transport and is kept as-is.
127
126
 
128
- All three transports use the same `HostBrowserProxy` → `ExtensionCdpClient` pipeline. The `browser_status` output distinguishes the transport via the `details.transport` field: `"extension-ws"` or `"macos-sse"`.
127
+ **Host-bridge cooldown:** a `host-bridge` transport failure records a per-actor cooldown (`recordHostBridgeCooldown`, keyed by `sourceActorPrincipalId`, `__default__` when unresolved) for the same `desktopAuto.cooldownMs` window; while it is active the factory skips the bridge candidate (log `CDP factory: host-bridge skipped (cooldown active)`) and the turn drops straight to cdp-inspect/local. Per-actor because on a multi-actor cloud daemon the bridge reaches a different desktop per actor, so one actor's missing debug port must not suppress another's only route to their Chrome. Never applies to the `extension` candidate.
129
128
 
130
129
  **Fallback criteria for cdp-inspect (desktop-auto):**
131
130
 
@@ -360,7 +360,7 @@ export interface WakeOptions {
360
360
  * the conversation belongs to. Used by fork-based memory retrospectives to
361
361
  * render the SOURCE conversation's persona sections — both for review
362
362
  * quality and for byte-parity with the source's cached system-prompt
363
- * prefix. May also pin `hasNoClient` for the prompt build (see
363
+ * prefix. May also pin presence for the prompt build (see
364
364
  * {@link SystemPromptPersonaOverride}). Prompt-build selection only; trust
365
365
  * class and approval semantics are governed solely by `trustContext`.
366
366
  */
@@ -400,14 +400,14 @@ export interface WakeOptions {
400
400
  */
401
401
  cronRunId?: string;
402
402
  /**
403
- * Run the woken turn clientless: pin `hasNoClient = true` for the duration of
404
- * the agent-loop run (restored after). Wakes bypass the orchestrator's
405
- * turn-start interactivity setup, so a wake on a conversation with no client
406
- * attached otherwise derives `isInteractive: true` (the default
407
- * `hasNoClient = false`). Pinning it makes `conversation-tool-setup` derive
408
- * `isInteractive: false`, which `policy-context` maps to `background`
409
- * (guardian) / `headless` (unknown) — so a side-effecting tool that would
410
- * prompt is denied instead of stalling on a client that isn't there.
403
+ * Run the woken turn clientless: pin the per-turn presence
404
+ * (`currentTurnIsNonInteractive = true`, which `hasNoClient` reads) for the
405
+ * duration of the agent-loop run (restored after). Wakes bypass the
406
+ * orchestrator's turn-start interactivity setup, so the pin is what makes
407
+ * `conversation-tool-setup` derive `isInteractive: false`, which
408
+ * `policy-context` maps to `background` (guardian) / `headless` (unknown),
409
+ * so a side-effecting tool that would prompt is denied instead of stalling
410
+ * on a client that isn't there.
411
411
  */
412
412
  clientless?: boolean;
413
413
  }
@@ -1454,7 +1454,8 @@ export async function wakeAgentForOpportunity(
1454
1454
  const priorCallSite = conversation.currentCallSite;
1455
1455
  const priorTurnOverrideProfile = conversation.currentTurnOverrideProfile;
1456
1456
  const priorTurnCronRunId = conversation.currentTurnCronRunId;
1457
- const priorHasNoClient = conversation.hasNoClient;
1457
+ const priorTurnIsNonInteractive =
1458
+ conversation.currentTurnIsNonInteractive;
1458
1459
  const priorTurnTrust = conversation.currentTurnTrustContext;
1459
1460
  conversation.currentCallSite = callSite;
1460
1461
  conversation.currentTurnOverrideProfile = overrideProfile;
@@ -1462,7 +1463,9 @@ export async function wakeAgentForOpportunity(
1462
1463
  // delegates work to subagents whose usage must attribute to that firing.
1463
1464
  conversation.currentTurnCronRunId = opts.cronRunId ?? null;
1464
1465
  if (opts.clientless) {
1465
- conversation.hasNoClient = true;
1466
+ // Presence is per-turn state; a clientless wake declares no human is
1467
+ // present for the duration of its dispatch.
1468
+ conversation.currentTurnIsNonInteractive = true;
1466
1469
  }
1467
1470
  // Per-turn guardian elevation for the wake's tools, set after the pre-run
1468
1471
  // reads so a pre-run failure can't leak it; restored in the finally.
@@ -1550,7 +1553,7 @@ export async function wakeAgentForOpportunity(
1550
1553
  conversation.currentCallSite = priorCallSite;
1551
1554
  conversation.currentTurnOverrideProfile = priorTurnOverrideProfile;
1552
1555
  conversation.currentTurnCronRunId = priorTurnCronRunId;
1553
- conversation.hasNoClient = priorHasNoClient;
1556
+ conversation.currentTurnIsNonInteractive = priorTurnIsNonInteractive;
1554
1557
  conversation.currentTurnTrustContext = priorTurnTrust;
1555
1558
  }
1556
1559
 
@@ -32,7 +32,7 @@ import {
32
32
  conversations,
33
33
  } from "../../../persistence/schema/index.js";
34
34
  import { ROUTES as CONVERSATION_LIST_ROUTES } from "../conversation-list-routes.js";
35
- import { BadRequestError } from "../errors.js";
35
+ import { BadRequestError, NotFoundError } from "../errors.js";
36
36
  import type { RouteDefinition } from "../types.js";
37
37
 
38
38
  // ---------------------------------------------------------------------------
@@ -246,6 +246,42 @@ describe("GET /v1/conversations — conversationType", () => {
246
246
  });
247
247
  });
248
248
 
249
+ describe("GET /v1/conversations/:id", () => {
250
+ const getHandler = findHandler(CONVERSATION_LIST_ROUTES, "getConversation");
251
+
252
+ beforeEach(() => {
253
+ clearConversations();
254
+ });
255
+
256
+ test("serves a listed conversation", () => {
257
+ const conv = createConversation({ title: "listed" });
258
+
259
+ const detail = getHandler({ pathParams: { id: conv.id } }) as {
260
+ conversation: { id: string; conversationType: string };
261
+ };
262
+
263
+ expect(detail.conversation.id).toBe(conv.id);
264
+ expect(detail.conversation.conversationType).toBe("standard");
265
+ });
266
+
267
+ test("does not serve a legacy private row the listing hides", () => {
268
+ // "private" is not a creatable type (legacy rows only); every listing
269
+ // hides it by type and the wire type collapses it to "standard", so a
270
+ // client holding only a stale id must get the same answer the listing
271
+ // gives: not there.
272
+ const conv = createConversation({ title: "hidden" });
273
+ rawRun(
274
+ "test:makePrivate",
275
+ "UPDATE conversations SET conversation_type = 'private' WHERE id = ?",
276
+ conv.id,
277
+ );
278
+
279
+ expect(() => getHandler({ pathParams: { id: conv.id } })).toThrow(
280
+ NotFoundError,
281
+ );
282
+ });
283
+ });
284
+
249
285
  describe("GET /v1/conversations with groupId", () => {
250
286
  function seedInGroup(title: string, groupId: string): string {
251
287
  const conv = createConversation(title);
@@ -514,6 +550,139 @@ describe("GET /v1/conversations with groupId", () => {
514
550
  });
515
551
  });
516
552
 
553
+ describe("GET /v1/conversations with needsAttention", () => {
554
+ function seedUnseen(conversationId: string): void {
555
+ projectAssistantMessage({
556
+ conversationId,
557
+ messageId: `msg-${conversationId}`,
558
+ messageAt: Date.now(),
559
+ });
560
+ }
561
+
562
+ function markSeen(conversationId: string): void {
563
+ recordConversationSeenSignal({
564
+ conversationId,
565
+ sourceChannel: "vellum",
566
+ signalType: "macos_conversation_opened",
567
+ confidence: "explicit",
568
+ source: "test",
569
+ });
570
+ }
571
+
572
+ beforeEach(() => {
573
+ getDb().delete(conversationAttentionEvents).run();
574
+ getDb().delete(conversationAssistantAttentionState).run();
575
+ clearConversations();
576
+ });
577
+
578
+ test("returns only conversations with an unseen latest assistant message", async () => {
579
+ const unseen = createConversation("needs-attention");
580
+ seedUnseen(unseen.id);
581
+ const seen = createConversation("already-seen");
582
+ seedUnseen(seen.id);
583
+ markSeen(seen.id);
584
+ // No attention projection at all: not unseen, so not returned. The
585
+ // filter's inner join is what excludes it; a left join would leak it.
586
+ createConversation("never-projected");
587
+
588
+ const result = await invoke({ needsAttention: "true" });
589
+
590
+ expect(result.conversations.map((c) => c.title)).toEqual([
591
+ "needs-attention",
592
+ ]);
593
+ });
594
+
595
+ test("omitting the filter leaves every list unchanged, join and all", async () => {
596
+ /* The sensitivity check for the conditional join: with the filter off,
597
+ rows with no attention row must still be listed. If the join were
598
+ applied unconditionally, "never-projected" would vanish from the
599
+ plain list, which is every list the app has today. */
600
+ const unseen = createConversation("has-attention-row");
601
+ seedUnseen(unseen.id);
602
+ createConversation("never-projected");
603
+
604
+ const result = await invoke();
605
+
606
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
607
+ "has-attention-row",
608
+ "never-projected",
609
+ ]);
610
+ });
611
+
612
+ test("hasMore and the total describe the filtered set, not the whole table", async () => {
613
+ /* countConversations reads through the same where AND the same join;
614
+ a page and its total have to agree or the client's hasMore lies. */
615
+ for (let i = 0; i < 3; i++) {
616
+ const c = createConversation(`unseen-${i}`);
617
+ seedUnseen(c.id);
618
+ }
619
+ for (let i = 0; i < 5; i++) {
620
+ createConversation(`quiet-${i}`);
621
+ }
622
+
623
+ const page = await invoke({ needsAttention: "true", limit: "2" });
624
+
625
+ expect(page.conversations).toHaveLength(2);
626
+ expect(page.hasMore).toBe(true);
627
+ const rest = await invoke({
628
+ needsAttention: "true",
629
+ limit: "2",
630
+ offset: "2",
631
+ });
632
+ expect(rest.conversations).toHaveLength(1);
633
+ expect(rest.hasMore).toBe(false);
634
+ });
635
+
636
+ test("an attention-scoped first page has no pinned rows appended to it", async () => {
637
+ /* Same rule as the group-scoped page: the pinned injection exists for
638
+ a client reading Pinned out of the unfiltered list, and a caller
639
+ that asked for the unseen subset is not that client. A seen pinned
640
+ row appended here would be a row outside the filter, on a page whose
641
+ hasMore was computed from the filtered count. */
642
+ const unseen = createConversation("unseen-only");
643
+ seedUnseen(unseen.id);
644
+ const pinnedSeen = createConversation("pinned-and-seen");
645
+ rawRun(
646
+ "test:pinConversation",
647
+ "UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
648
+ pinnedSeen.id,
649
+ );
650
+
651
+ const result = await invoke({ needsAttention: "true" });
652
+
653
+ expect(result.conversations.map((c) => c.title)).toEqual(["unseen-only"]);
654
+ });
655
+
656
+ test("composes with the other filters", async () => {
657
+ const group = createGroup("Work");
658
+ const inGroupUnseen = createConversation("in-group-unseen");
659
+ seedUnseen(inGroupUnseen.id);
660
+ rawRun(
661
+ "test:fileInGroup",
662
+ "UPDATE conversations SET group_id = ? WHERE id = ?",
663
+ group.id,
664
+ inGroupUnseen.id,
665
+ );
666
+ const outOfGroupUnseen = createConversation("out-of-group-unseen");
667
+ seedUnseen(outOfGroupUnseen.id);
668
+
669
+ const result = await invoke({ needsAttention: "true", groupId: group.id });
670
+
671
+ expect(result.conversations.map((c) => c.title)).toEqual([
672
+ "in-group-unseen",
673
+ ]);
674
+ });
675
+
676
+ test('any value other than "true" is rejected with a 400', () => {
677
+ /* Same posture as conversationType: silently reading a typo or a newer
678
+ client's value as "no filter" would hand back the full list where a
679
+ subset was asked for, and that skew is invisible to the client. */
680
+ for (const bad of ["false", "1", "yes", "TRUE"]) {
681
+ expect(() => invoke({ needsAttention: bad })).toThrow(BadRequestError);
682
+ }
683
+ });
684
+ });
685
+
517
686
  describe("GET /v1/conversations/unread-count", () => {
518
687
  const unreadCountHandler = findHandler(
519
688
  CONVERSATION_LIST_ROUTES,