@vellumai/assistant 0.11.5 → 0.11.6-staging.1

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 (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,598 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+
3
+ import { initializeDb } from "../../../persistence/db-init.js";
4
+ import type {
5
+ StreamingTranscriber,
6
+ SttStreamServerEvent,
7
+ } from "../../../stt/types.js";
8
+ import type { WatchSessionSummary } from "../../../watch/watch-session-manager.js";
9
+ import { WatchSessionManager } from "../../../watch/watch-session-manager.js";
10
+ import { renderWatchTimeline } from "../../../watch/watch-timeline.js";
11
+ import type { HostObservation } from "../../host-observe.js";
12
+ import {
13
+ closeWatchIngress,
14
+ drainWatchRetros,
15
+ reopenWatchIngressForTest,
16
+ type WatchStreamServerFrame,
17
+ WatchStreamSession,
18
+ type WatchStreamSocket,
19
+ } from "../watch-routes.js";
20
+
21
+ await initializeDb();
22
+
23
+ const PRINCIPAL_ID = "principal-watch-stream-test";
24
+
25
+ /** A socket that keeps every frame the session sent it. */
26
+ class FakeSocket implements WatchStreamSocket {
27
+ readonly frames: WatchStreamServerFrame[] = [];
28
+ closeCode: number | null = null;
29
+ closeReason: string | null = null;
30
+
31
+ send(data: string): void {
32
+ this.frames.push(JSON.parse(data) as WatchStreamServerFrame);
33
+ }
34
+
35
+ close(code?: number, reason?: string): void {
36
+ this.closeCode = code ?? null;
37
+ this.closeReason = reason ?? null;
38
+ }
39
+
40
+ types(): string[] {
41
+ return this.frames.map((frame) => frame.type);
42
+ }
43
+
44
+ /**
45
+ * The same sequence with the capture acknowledgements taken out.
46
+ *
47
+ * Every session reads the screen as it opens and again around narration, so
48
+ * `observation` frames land in the middle of every sequence here, at moments
49
+ * the manager's cadence decides rather than the test. Cases whose subject is
50
+ * the lifecycle read this; cases whose subject is the capture signal read
51
+ * `types()` and say exactly where the frames fall.
52
+ */
53
+ lifecycle(): string[] {
54
+ return this.types().filter((type) => type !== "observation");
55
+ }
56
+
57
+ firstOfType<T extends WatchStreamServerFrame["type"]>(
58
+ type: T,
59
+ ): Extract<WatchStreamServerFrame, { type: T }> | undefined {
60
+ return this.frames.find((frame) => frame.type === type) as
61
+ | Extract<WatchStreamServerFrame, { type: T }>
62
+ | undefined;
63
+ }
64
+ }
65
+
66
+ /** A transcriber the test drives by hand. */
67
+ class FakeTranscriber implements StreamingTranscriber {
68
+ readonly providerId = "deepgram" as const;
69
+ readonly boundaryId = "daemon-streaming" as const;
70
+ readonly audio: { bytes: Buffer; mimeType: string }[] = [];
71
+ stopCount = 0;
72
+ private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
73
+
74
+ async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
75
+ this.onEvent = onEvent;
76
+ }
77
+
78
+ sendAudio(audio: Buffer, mimeType: string): void {
79
+ this.audio.push({ bytes: audio, mimeType });
80
+ }
81
+
82
+ stop(): void {
83
+ this.stopCount += 1;
84
+ }
85
+
86
+ emit(event: SttStreamServerEvent): void {
87
+ this.onEvent?.(event);
88
+ }
89
+ }
90
+
91
+ function observation(): HostObservation {
92
+ return {
93
+ ok: true,
94
+ axTree: "Window: Editor\n[1] Button Save",
95
+ };
96
+ }
97
+
98
+ /**
99
+ * A manager wired to a scripted screen reader, so a session's cadence is a
100
+ * function of what the test says the host answered rather than of a real
101
+ * desktop client.
102
+ */
103
+ function newManager() {
104
+ const observeCalls: (string | undefined)[] = [];
105
+ const manager = new WatchSessionManager({
106
+ observe: async (options) => {
107
+ observeCalls.push(options.sourceActorPrincipalId);
108
+ return observation();
109
+ },
110
+ });
111
+ return { manager, observeCalls };
112
+ }
113
+
114
+ interface HarnessOverrides {
115
+ manager?: WatchSessionManager;
116
+ transcriber?: FakeTranscriber | null;
117
+ principalId?: string | undefined;
118
+ idleTimeoutMs?: number;
119
+ /** How long the stand-in retrospective takes before it settles. */
120
+ retroDelayMs?: number;
121
+ }
122
+
123
+ function newSession(overrides: HarnessOverrides = {}) {
124
+ const ws = new FakeSocket();
125
+ const transcriber =
126
+ overrides.transcriber === undefined
127
+ ? new FakeTranscriber()
128
+ : overrides.transcriber;
129
+ const manager = overrides.manager ?? newManager().manager;
130
+ // The retrospective is a full agent turn, so every session in this file gets
131
+ // a stand-in for it and the tests read what it was handed. `finished` flips
132
+ // once the stand-in returns, which is what a drain has to wait for.
133
+ const retros: WatchSessionSummary[] = [];
134
+ const finished: WatchSessionSummary[] = [];
135
+ const session = new WatchStreamSession(ws, {
136
+ mimeType: "audio/webm",
137
+ manager,
138
+ resolveTranscriber: async () => transcriber,
139
+ resolveActorPrincipalId: async () =>
140
+ "principalId" in overrides ? overrides.principalId : PRINCIPAL_ID,
141
+ runRetro: async (summary) => {
142
+ retros.push(summary);
143
+ if (overrides.retroDelayMs !== undefined) {
144
+ await Bun.sleep(overrides.retroDelayMs);
145
+ }
146
+ finished.push(summary);
147
+ },
148
+ ...(overrides.idleTimeoutMs !== undefined
149
+ ? { idleTimeoutMs: overrides.idleTimeoutMs }
150
+ : {}),
151
+ });
152
+ return { ws, transcriber, manager, session, retros, finished };
153
+ }
154
+
155
+ describe("watch stream session", () => {
156
+ // The ingress latch is module state shared by every test in this file.
157
+ afterEach(() => {
158
+ reopenWatchIngressForTest();
159
+ });
160
+
161
+ test("observes on speech onset and acknowledges nothing", async () => {
162
+ // A controllable clock, so the test can step past the observation floor
163
+ // without waiting it out in real time.
164
+ let nowMs = 0;
165
+ const observeCalls: (string | undefined)[] = [];
166
+ const manager = new WatchSessionManager({
167
+ now: () => nowMs,
168
+ observe: async (options) => {
169
+ observeCalls.push(options.sourceActorPrincipalId);
170
+ return observation();
171
+ },
172
+ });
173
+ const { ws, transcriber, session } = newSession({ manager });
174
+ await session.start();
175
+ await Bun.sleep(5);
176
+
177
+ // The session's opening observation. Step past the floor it just spent so
178
+ // the onset below is free to read the screen rather than being collapsed
179
+ // into it.
180
+ expect(observeCalls).toHaveLength(1);
181
+ nowMs += 6_000;
182
+
183
+ transcriber!.emit({ type: "turn-start" });
184
+ await Bun.sleep(5);
185
+
186
+ expect(observeCalls).toHaveLength(2);
187
+ // No `entry`: onset carries no text, and the final that follows is what
188
+ // files the narration. Acknowledging here would report an entry the
189
+ // timeline does not have. The two reads themselves are acknowledged, which
190
+ // is the case below.
191
+ expect(ws.lifecycle()).toEqual(["ready"]);
192
+
193
+ session.handleMessage(JSON.stringify({ type: "stop" }));
194
+ transcriber!.emit({ type: "closed" });
195
+ });
196
+
197
+ test("starts, records narration, and stops", async () => {
198
+ const { manager, observeCalls } = newManager();
199
+ const { ws, transcriber, session } = newSession({ manager });
200
+ await session.start();
201
+
202
+ const ready = ws.firstOfType("ready");
203
+ expect(ready).toBeDefined();
204
+ expect(manager.isActive(ready!.conversationId)).toBe(true);
205
+
206
+ transcriber!.emit({ type: "final", text: " opening the invoice " });
207
+ // The narration lands synchronously; the screen read it triggers does not.
208
+ await Bun.sleep(5);
209
+
210
+ expect(ws.lifecycle()).toEqual(["ready", "entry"]);
211
+ expect(observeCalls).toEqual([PRINCIPAL_ID]);
212
+
213
+ const rendered = renderWatchTimeline(ready!.sessionId);
214
+ expect(rendered.totalEntries).toBe(2);
215
+ expect(rendered.text).toContain("opening the invoice");
216
+
217
+ session.handleMessage(JSON.stringify({ type: "stop" }));
218
+ expect(transcriber!.stopCount).toBe(1);
219
+ // The provider flushes and then reports its own close.
220
+ transcriber!.emit({ type: "closed" });
221
+
222
+ expect(ws.lifecycle()).toEqual(["ready", "entry", "closed"]);
223
+ expect(manager.isActive()).toBe(false);
224
+ expect(session.isClosed).toBe(true);
225
+ });
226
+
227
+ /**
228
+ * The capture signal, which is the only word a client ever gets that its
229
+ * screen was read. Every case here is about it being true: a frame per read
230
+ * that landed, and no frame at all for a read that did not.
231
+ */
232
+ describe("capture acknowledgements", () => {
233
+ test("sends one frame per screen read that landed", async () => {
234
+ let nowMs = 0;
235
+ const manager = new WatchSessionManager({
236
+ now: () => nowMs,
237
+ observe: async () => observation(),
238
+ });
239
+ const { ws, transcriber, session } = newSession({ manager });
240
+ await session.start();
241
+ await Bun.sleep(5);
242
+
243
+ // The session's own opening read, acknowledged after `ready`: the
244
+ // manager is claimed before that frame goes out and the read behind it
245
+ // settles later.
246
+ expect(ws.types()).toEqual(["ready", "observation"]);
247
+
248
+ // Past the floor, so the onset below reads the screen rather than being
249
+ // collapsed into the read above.
250
+ nowMs += 6_000;
251
+ transcriber!.emit({ type: "turn-start" });
252
+ await Bun.sleep(5);
253
+
254
+ expect(ws.types()).toEqual(["ready", "observation", "observation"]);
255
+
256
+ session.destroy();
257
+ });
258
+
259
+ test("stays silent for a read the host could not serve", async () => {
260
+ // The shape every failure resolves to, timeouts included:
261
+ // `observeHostScreen` answers `{ ok: false }` rather than throwing, and a
262
+ // read that outran its budget is that answer with `timedOut` set.
263
+ const manager = new WatchSessionManager({
264
+ observe: async () => ({
265
+ ok: false as const,
266
+ reason: "No connected client supports screen observation",
267
+ timedOut: true,
268
+ }),
269
+ });
270
+ const { ws, transcriber, session } = newSession({ manager });
271
+ await session.start();
272
+ await Bun.sleep(5);
273
+
274
+ transcriber!.emit({ type: "final", text: "still narrating" });
275
+ await Bun.sleep(5);
276
+
277
+ // The narration is filed and acknowledged; the screen behind it was
278
+ // never read, and nothing here says it was.
279
+ expect(ws.types()).toEqual(["ready", "entry"]);
280
+
281
+ session.destroy();
282
+ });
283
+
284
+ test("stays silent for a read the session ended underneath", async () => {
285
+ const releases: ((observed: HostObservation) => void)[] = [];
286
+ const manager = new WatchSessionManager({
287
+ observe: () =>
288
+ new Promise<HostObservation>((resolve) => {
289
+ releases.push(resolve);
290
+ }),
291
+ });
292
+ const { ws, session } = newSession({ manager });
293
+ await session.start();
294
+ await Bun.sleep(5);
295
+ expect(releases).toHaveLength(1);
296
+
297
+ // The session ends while its opening read is still out. What comes back
298
+ // describes a screen belonging to a session that is over, and the
299
+ // acknowledgement would land after `closed`.
300
+ session.destroy();
301
+ releases[0]!(observation());
302
+ await Bun.sleep(5);
303
+
304
+ expect(ws.types()).toEqual(["ready", "closed"]);
305
+ });
306
+ });
307
+
308
+ test("forwards binary and base64 audio frames to the transcriber", async () => {
309
+ const { ws, transcriber, session } = newSession();
310
+ await session.start();
311
+
312
+ session.handleBinaryAudio(Buffer.from([1, 2, 3]));
313
+ session.handleMessage(
314
+ JSON.stringify({
315
+ type: "audio",
316
+ audio: Buffer.from([4, 5]).toString("base64"),
317
+ mimeType: "audio/pcm",
318
+ }),
319
+ );
320
+
321
+ expect(transcriber!.audio).toHaveLength(2);
322
+ expect([...transcriber!.audio[0]!.bytes]).toEqual([1, 2, 3]);
323
+ expect(transcriber!.audio[0]!.mimeType).toBe("audio/webm");
324
+ expect([...transcriber!.audio[1]!.bytes]).toEqual([4, 5]);
325
+ expect(transcriber!.audio[1]!.mimeType).toBe("audio/pcm");
326
+ expect(ws.lifecycle()).toEqual(["ready"]);
327
+
328
+ session.destroy();
329
+ });
330
+
331
+ test("draws no transcript: partials produce no frames", async () => {
332
+ const { ws, transcriber, session } = newSession();
333
+ await session.start();
334
+
335
+ transcriber!.emit({ type: "partial", text: "half a sen" });
336
+ transcriber!.emit({ type: "turn-end", text: "half a sentence" });
337
+ transcriber!.emit({ type: "final", text: " " });
338
+
339
+ expect(ws.lifecycle()).toEqual(["ready"]);
340
+
341
+ session.destroy();
342
+ });
343
+
344
+ test("an unresolvable actor principal fails without opening a session", async () => {
345
+ const { manager } = newManager();
346
+ const { ws, transcriber, session } = newSession({
347
+ manager,
348
+ principalId: undefined,
349
+ });
350
+ await session.start();
351
+
352
+ expect(ws.types()).toEqual(["error", "closed"]);
353
+ expect(ws.firstOfType("error")!.category).toBe("session-error");
354
+ expect(ws.closeCode).toBe(1008);
355
+ expect(manager.isActive()).toBe(false);
356
+ // The provider stream is never opened for a session that could observe
357
+ // nothing, so there is nothing to stop.
358
+ expect(transcriber!.stopCount).toBe(0);
359
+ expect(session.isClosed).toBe(true);
360
+ });
361
+
362
+ test("provider resolution failure yields a clean error then closed", async () => {
363
+ const { manager } = newManager();
364
+ const { ws, session } = newSession({ manager, transcriber: null });
365
+ await session.start();
366
+
367
+ expect(ws.types()).toEqual(["error", "closed"]);
368
+ expect(ws.firstOfType("error")!.category).toBe("provider-error");
369
+ expect(manager.isActive()).toBe(false);
370
+ expect(session.isClosed).toBe(true);
371
+ });
372
+
373
+ test("a second socket is turned away without disturbing the running session", async () => {
374
+ const { manager } = newManager();
375
+ const first = newSession({ manager });
376
+ await first.session.start();
377
+ const runningConversationId = first.ws.firstOfType("ready")!.conversationId;
378
+
379
+ const second = newSession({ manager });
380
+ await second.session.start();
381
+
382
+ expect(second.ws.types()).toEqual(["error", "closed"]);
383
+ expect(second.ws.firstOfType("error")!.message).toContain(
384
+ "already running",
385
+ );
386
+ // The loser's teardown must not release the winner's slot.
387
+ expect(manager.isActive(runningConversationId)).toBe(true);
388
+ expect(second.transcriber!.stopCount).toBe(1);
389
+
390
+ first.session.destroy();
391
+ expect(manager.isActive()).toBe(false);
392
+ });
393
+
394
+ test("an abandoned socket is torn down by the idle timeout", async () => {
395
+ const { manager } = newManager();
396
+ const { ws, transcriber, session } = newSession({
397
+ manager,
398
+ idleTimeoutMs: 20,
399
+ });
400
+ await session.start();
401
+ expect(manager.isActive()).toBe(true);
402
+
403
+ await Bun.sleep(60);
404
+
405
+ expect(ws.lifecycle()).toEqual(["ready", "error", "closed"]);
406
+ expect(ws.firstOfType("error")!.category).toBe("timeout");
407
+ expect(ws.closeCode).toBe(1000);
408
+ expect(transcriber!.stopCount).toBe(1);
409
+ expect(manager.isActive()).toBe(false);
410
+ expect(session.isClosed).toBe(true);
411
+ });
412
+
413
+ test("inbound audio keeps the idle timeout at bay", async () => {
414
+ const { manager } = newManager();
415
+ const { ws, session } = newSession({ manager, idleTimeoutMs: 40 });
416
+ await session.start();
417
+
418
+ for (let i = 0; i < 4; i += 1) {
419
+ await Bun.sleep(15);
420
+ session.handleBinaryAudio(Buffer.from([i]));
421
+ }
422
+
423
+ expect(ws.lifecycle()).toEqual(["ready"]);
424
+ expect(manager.isActive()).toBe(true);
425
+
426
+ session.destroy();
427
+ });
428
+
429
+ test("teardown runs the retrospective once, on the session it just ended", async () => {
430
+ const { manager } = newManager();
431
+ const { ws, transcriber, session, retros } = newSession({ manager });
432
+ await session.start();
433
+ const ready = ws.firstOfType("ready")!;
434
+
435
+ transcriber!.emit({ type: "final", text: "renaming the export" });
436
+ await Bun.sleep(5);
437
+
438
+ expect(retros).toHaveLength(0);
439
+
440
+ session.handleMessage(JSON.stringify({ type: "stop" }));
441
+ transcriber!.emit({ type: "closed" });
442
+
443
+ expect(retros).toHaveLength(1);
444
+ expect(retros[0]!.sessionId).toBe(ready.sessionId);
445
+ expect(retros[0]!.conversationId).toBe(ready.conversationId);
446
+ expect(retros[0]!.entryCount).toBe(2);
447
+
448
+ // The close that follows the provider's own is a no-op, so a socket that
449
+ // reports both does not report the session twice.
450
+ session.handleClose(1000, "session complete");
451
+ expect(retros).toHaveLength(1);
452
+ });
453
+
454
+ test("a socket turned away as busy runs no retrospective", async () => {
455
+ const { manager } = newManager();
456
+ const first = newSession({ manager });
457
+ await first.session.start();
458
+
459
+ const second = newSession({ manager });
460
+ await second.session.start();
461
+
462
+ expect(second.retros).toHaveLength(0);
463
+
464
+ first.session.handleClose(1000, "done");
465
+ expect(first.retros).toHaveLength(1);
466
+ });
467
+
468
+ test("shutdown tears the session down without starting a retrospective", async () => {
469
+ const { manager } = newManager();
470
+ const { transcriber, session, retros } = newSession({ manager });
471
+ await session.start();
472
+ transcriber!.emit({ type: "final", text: "renaming the export" });
473
+ await Bun.sleep(5);
474
+
475
+ session.destroy();
476
+
477
+ // The process is going away, so a turn started here would be killed
478
+ // partway through. The timeline it would have read outlives the daemon.
479
+ expect(retros).toHaveLength(0);
480
+ expect(manager.isActive()).toBe(false);
481
+ expect(session.isClosed).toBe(true);
482
+ });
483
+
484
+ test("shutdown waits for a retrospective that was already running", async () => {
485
+ const { manager } = newManager();
486
+ const { session, retros, finished } = newSession({
487
+ manager,
488
+ retroDelayMs: 30,
489
+ });
490
+ await session.start();
491
+
492
+ session.handleClose(1000, "done");
493
+ expect(retros).toHaveLength(1);
494
+ // Teardown does not block on the turn, so it is still going here.
495
+ expect(finished).toHaveLength(0);
496
+
497
+ expect(await drainWatchRetros(2_000)).toBe(0);
498
+
499
+ expect(finished).toHaveLength(1);
500
+ });
501
+
502
+ test("a settled retrospective is forgotten, so the registry stays bounded", async () => {
503
+ const { manager } = newManager();
504
+ const first = newSession({ manager });
505
+ await first.session.start();
506
+ first.session.handleClose(1000, "done");
507
+ expect(await drainWatchRetros(2_000)).toBe(0);
508
+
509
+ const second = newSession({ manager });
510
+ await second.session.start();
511
+ second.session.handleClose(1000, "done");
512
+
513
+ // Only the second session's retro is outstanding. The first was dropped
514
+ // when it settled rather than accumulating for the life of the process.
515
+ expect(await drainWatchRetros(2_000)).toBe(0);
516
+ expect(first.finished).toHaveLength(1);
517
+ expect(second.finished).toHaveLength(1);
518
+ });
519
+
520
+ test("the drain gives up rather than holding shutdown open", async () => {
521
+ const { manager } = newManager();
522
+ const { session, finished } = newSession({ manager, retroDelayMs: 400 });
523
+ await session.start();
524
+
525
+ session.handleClose(1000, "done");
526
+ const startedAtMs = Date.now();
527
+ const unsettled = await drainWatchRetros(20);
528
+
529
+ // It returns on its own deadline, and the unfinished turn is cut off
530
+ // rather than allowed to fail the shutdown that is cutting it off.
531
+ expect(unsettled).toBe(1);
532
+ expect(Date.now() - startedAtMs).toBeLessThan(300);
533
+ expect(finished).toHaveLength(0);
534
+ // Let the straggler settle so it does not leak into the next test.
535
+ await Bun.sleep(450);
536
+ });
537
+
538
+ test("a session arriving after ingress closes is refused, not opened", async () => {
539
+ const { manager } = newManager();
540
+ const { ws, transcriber, session, retros } = newSession({ manager });
541
+
542
+ closeWatchIngress();
543
+ await session.start();
544
+
545
+ expect(ws.types()).toEqual(["error", "closed"]);
546
+ expect(ws.firstOfType("error")!.message).toContain("shutting down");
547
+ expect(ws.closeCode).toBe(1001);
548
+ // Nothing was claimed, so nothing is left running for the drain to miss.
549
+ expect(manager.isActive()).toBe(false);
550
+ expect(retros).toHaveLength(0);
551
+ expect(transcriber!.stopCount).toBe(0);
552
+ expect(session.isClosed).toBe(true);
553
+ });
554
+
555
+ test("a session that opens during the drain registers no retrospective", async () => {
556
+ const { manager } = newManager();
557
+ const running = newSession({ manager, retroDelayMs: 60 });
558
+ await running.session.start();
559
+ running.session.handleClose(1000, "done");
560
+
561
+ // Shutdown's order: ingress first, then the open sessions, then the wait.
562
+ closeWatchIngress();
563
+
564
+ // A socket that opens and closes while the wait is already under way. Its
565
+ // retrospective would register after the drain took its snapshot, so the
566
+ // drain would return believing nothing was left. The long stand-in keeps
567
+ // it running past the drain, which is what makes the count tell the truth.
568
+ const latecomer = newSession({ manager, retroDelayMs: 500 });
569
+ setTimeout(() => {
570
+ void latecomer.session.start().then(() => {
571
+ latecomer.session.handleClose(1000, "done");
572
+ });
573
+ }, 15);
574
+
575
+ expect(await drainWatchRetros(2_000)).toBe(0);
576
+
577
+ expect(running.finished).toHaveLength(1);
578
+ expect(latecomer.retros).toHaveLength(0);
579
+
580
+ // Let any straggler settle so it cannot leak into the next test.
581
+ await Bun.sleep(600);
582
+ });
583
+
584
+ test("a dropped socket releases the session slot", async () => {
585
+ const { manager } = newManager();
586
+ const { transcriber, session } = newSession({ manager });
587
+ await session.start();
588
+
589
+ session.handleClose(1006, "abnormal closure");
590
+
591
+ expect(manager.isActive()).toBe(false);
592
+ expect(transcriber!.stopCount).toBe(1);
593
+
594
+ // A second close is a no-op rather than a second stop.
595
+ session.handleClose(1006, "abnormal closure");
596
+ expect(transcriber!.stopCount).toBe(1);
597
+ });
598
+ });