@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.
- package/AGENTS.md +8 -2
- package/ARCHITECTURE.md +2 -0
- package/docs/architecture/memory.md +15 -0
- package/docs/browser-use-architecture-phase2.md +128 -56
- package/docs/flux-turn-detection-spike.md +11 -6
- package/docs/guardian-request-flow.md +35 -0
- package/knip.json +3 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/openapi.yaml +136 -76
- package/package.json +1 -1
- package/scripts/write-plugin-api-shim.ts +10 -0
- package/src/__tests__/app-control-flow.test.ts +1 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -2
- package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
- package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
- package/src/__tests__/compaction-events.test.ts +8 -10
- package/src/__tests__/conversation-agent-loop.test.ts +4 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +39 -62
- package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
- package/src/__tests__/conversation-slash-queue.test.ts +3 -0
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
- package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +1 -0
- package/src/__tests__/document-sync-tags.test.ts +0 -75
- package/src/__tests__/file-ops-service.test.ts +163 -30
- package/src/__tests__/filesystem-tools.test.ts +23 -24
- package/src/__tests__/gateway-only-guard.test.ts +2 -5
- package/src/__tests__/host-file-read-tool.test.ts +16 -19
- package/src/__tests__/http-user-message-parity.test.ts +1 -1
- package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
- package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
- package/src/__tests__/media-generate-image.test.ts +131 -21
- package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
- package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
- package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
- package/src/__tests__/proxy-approval-callback.test.ts +1 -0
- package/src/__tests__/qdrant-manager.test.ts +14 -1
- package/src/__tests__/run-due-schedules.test.ts +21 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
- package/src/__tests__/schedule-routes.test.ts +23 -0
- package/src/__tests__/schedule-store.test.ts +17 -0
- package/src/__tests__/secret-ingress-http.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +3 -3
- package/src/__tests__/starter-task-flow.test.ts +5 -4
- package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
- package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
- package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
- package/src/__tests__/subagent-tools.test.ts +81 -101
- package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
- package/src/__tests__/tool-executor.test.ts +5 -1
- package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
- package/src/__tests__/ui-visual-surface.test.ts +1 -1
- package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
- package/src/__tests__/ui-work-result-surface.test.ts +1 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
- package/src/__tests__/voice-session-bridge.test.ts +85 -29
- package/src/acp/session-manager.ts +8 -1
- package/src/api/events/host-file.ts +2 -2
- package/src/api/surfaces.ts +5 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
- package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
- package/src/calls/voice-session-bridge.ts +44 -25
- package/src/calls/voice-triage-escalate.ts +1 -0
- package/src/cli/bundled-modules.ts +29 -0
- package/src/cli/commands/db/repair.ts +4 -8
- package/src/cli/commands/domain.ts +6 -3
- package/src/cli/commands/email.ts +6 -3
- package/src/cli/commands/keys.ts +8 -3
- package/src/cli/commands/plugins.ts +85 -36
- package/src/cli/commands/schedules.ts +35 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
- package/src/config/assistant-feature-flags.ts +36 -15
- package/src/config/balanced-model-experiment.ts +35 -0
- package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
- package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
- package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
- package/src/config/bundled-skills/subagent/SKILL.md +17 -12
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
- package/src/config/call-site-defaults.ts +7 -0
- package/src/config/default-profile-catalog.ts +96 -4
- package/src/config/feature-flag-registry.json +11 -11
- package/src/config/skills.ts +9 -2
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
- package/src/daemon/conversation-agent-loop.ts +14 -13
- package/src/daemon/conversation-notifiers.ts +11 -9
- package/src/daemon/conversation-process.ts +0 -27
- package/src/daemon/conversation-runtime-assembly.ts +9 -2
- package/src/daemon/conversation-store.ts +4 -4
- package/src/daemon/conversation-surfaces.ts +27 -13
- package/src/daemon/conversation-tool-setup.ts +2 -4
- package/src/daemon/conversation.ts +104 -51
- package/src/daemon/doordash-steps.ts +2 -2
- package/src/daemon/lifecycle.ts +14 -1
- package/src/daemon/process-message.ts +0 -13
- package/src/daemon/windows-compiled-entry.ts +4 -0
- package/src/documents/document-store.ts +5 -235
- package/src/hooks/types.ts +5 -0
- package/src/ipc/gateway-flag-listener.ts +17 -3
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
- package/src/live-voice/live-voice-manager.ts +16 -3
- package/src/live-voice/live-voice-session.ts +63 -9
- package/src/live-voice/windows-compiled-live-voice.ts +4 -0
- package/src/monitoring/control.ts +1 -0
- package/src/monitoring/db-integrity-sample.ts +4 -5
- package/src/notifications/AGENTS.md +2 -0
- package/src/notifications/approval-card-data.ts +33 -0
- package/src/permissions/prompter.ts +1 -5
- package/src/persistence/conversation-queries.ts +66 -16
- package/src/persistence/embeddings/qdrant-manager.ts +84 -49
- package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
- package/src/persistence/schema/documents.ts +4 -4
- package/src/plugin-api/constants.ts +8 -0
- package/src/plugin-api/index.ts +5 -1
- package/src/plugin-api/webhook-url.ts +13 -11
- package/src/plugins/defaults/main.ts +6 -7
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
- package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
- package/src/plugins/defaults/memory/v3/injector.ts +8 -0
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
- package/src/plugins/defaults/memory/worker-control.ts +1 -0
- package/src/plugins/defaults/worker-entrypoints.ts +3 -0
- package/src/plugins/mtime-cache.ts +17 -0
- package/src/prompts/templates/system-sections.ts +0 -7
- package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
- package/src/providers/__tests__/retry-callsite.test.ts +20 -0
- package/src/providers/openai/chat-completions-provider.ts +11 -1
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
- package/src/routes/control.ts +1 -0
- package/src/routes/route-host-client.ts +1 -0
- package/src/runtime/AGENTS.md +16 -17
- package/src/runtime/agent-wake.ts +15 -12
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
- package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
- package/src/runtime/routes/conversation-list-routes.ts +54 -22
- package/src/runtime/routes/conversation-management-routes.ts +2 -3
- package/src/runtime/routes/conversation-routes.ts +11 -13
- package/src/runtime/routes/documents-routes.ts +3 -222
- package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
- package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
- package/src/runtime/routes/playground/inject-failures.ts +2 -2
- package/src/runtime/routes/playground/reset-circuit.ts +1 -1
- package/src/runtime/routes/schedule-routes.ts +94 -7
- package/src/runtime/routes/workspace-routes.ts +0 -9
- package/src/runtime/routes/workspace-utils.ts +3 -13
- package/src/runtime/services/conversation-serializer.ts +7 -2
- package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
- package/src/schedule/plugin-schedule-availability.ts +58 -0
- package/src/schedule/plugin-schedule-declarations.ts +23 -27
- package/src/schedule/plugin-schedule-reconciler.ts +12 -3
- package/src/schedule/schedule-store.ts +5 -1
- package/src/schedule/scheduler.ts +9 -4
- package/src/schedule/worker-control.ts +1 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
- package/src/subagent/consult-context.ts +11 -11
- package/src/subagent/consult-prompt.ts +26 -35
- package/src/subagent/manager.ts +20 -37
- package/src/subagent/notify.ts +7 -1
- package/src/subagent/types.ts +15 -13
- package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
- package/src/tools/acp/spawn.ts +6 -4
- package/src/tools/filesystem/read.ts +27 -10
- package/src/tools/host-filesystem/read.ts +27 -15
- package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
- package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
- package/src/tools/shared/filesystem/types.ts +5 -5
- package/src/tools/skills/scaffold-managed.ts +25 -7
- package/src/tools/subagent/spawn.ts +28 -88
- package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
- package/src/util/__tests__/worker-process-command.test.ts +37 -0
- package/src/util/logger.ts +16 -0
- package/src/util/worker-process.ts +37 -4
- package/src/windows-compiled-cli.ts +32 -0
- package/src/windows-compiled-entry.ts +4 -0
- package/src/windows-compiled-logger.ts +6 -0
- package/src/windows-compiled-worker-entry.ts +29 -0
- package/src/__tests__/document-workspace-file.test.ts +0 -467
- package/src/daemon/interactive-turn-sender.ts +0 -59
- package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
- 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
|
-
|
|
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("
|
|
575
|
-
const { transcriber } = await startSession(
|
|
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
|
|
581
|
-
(data) => data ===
|
|
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(
|
|
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,
|
|
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
|
/**
|
package/src/routes/control.ts
CHANGED
|
@@ -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.
|
package/src/runtime/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
| -------- | -------------------------- |
|
|
116
|
-
| 1 | **Extension / host proxy** | `
|
|
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
|
|
118
|
-
| 3 | **Local** | Always present as the final fallback
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* `
|
|
408
|
-
* `
|
|
409
|
-
*
|
|
410
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|