@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,566 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { beforeEach, describe, expect, jest, test } from "bun:test";
3
+
4
+ import { getConversation } from "../../persistence/conversation-crud.js";
5
+ import { initializeDb } from "../../persistence/db-init.js";
6
+ import type { HostObservation } from "../../runtime/host-observe.js";
7
+ import { WatchSessionManager } from "../watch-session-manager.js";
8
+ import { renderWatchTimeline } from "../watch-timeline.js";
9
+
10
+ await initializeDb();
11
+
12
+ const PRINCIPAL_ID = "principal-watch-test";
13
+
14
+ /** A 1x1 JPEG, the shape the host hands over on every observe. */
15
+ const SCREENSHOT_BASE64 =
16
+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==";
17
+
18
+ /** An ordinary observation: the accessibility tree carried the screen. */
19
+ function richObservation(): HostObservation {
20
+ return {
21
+ ok: true,
22
+ axTree: "Window: Editor\n[1] Button Save",
23
+ axDiff:
24
+ 'CHANGES SINCE LAST ACTION:\n~ Changed: [1] Save - value: "a" to "b"',
25
+ screenshot: SCREENSHOT_BASE64,
26
+ };
27
+ }
28
+
29
+ /** The shape the helper returns when accessibility found no focused window. */
30
+ function treelessObservation(): HostObservation {
31
+ return { ok: true, screenshot: SCREENSHOT_BASE64 };
32
+ }
33
+
34
+ /** The sentinel `AXTreeDiff` writes when the window was replaced wholesale. */
35
+ function navigatedObservation(): HostObservation {
36
+ return {
37
+ ok: true,
38
+ axTree: "Window: Browser\n[1] Link Home",
39
+ axDiff:
40
+ "CHANGES SINCE LAST ACTION:\nPage navigated - UI changed substantially (40 of 50 elements differ). Refer to the current screen state below.",
41
+ screenshot: SCREENSHOT_BASE64,
42
+ };
43
+ }
44
+
45
+ let nowMs = 1_000_000;
46
+
47
+ /**
48
+ * A manager wired to a scripted screen reader and the test's own clock, so a
49
+ * session's cadence is a function of the timeline the test writes rather than
50
+ * of how long the test took to run.
51
+ */
52
+ function newManager(
53
+ respond: (call: number) => HostObservation = () => richObservation(),
54
+ ) {
55
+ const calls: number[] = [];
56
+ const manager = new WatchSessionManager({
57
+ now: () => nowMs,
58
+ observe: async () => {
59
+ calls.push(nowMs);
60
+ return respond(calls.length);
61
+ },
62
+ });
63
+ return { manager, calls };
64
+ }
65
+
66
+ /**
67
+ * A manager whose screen reads hang until the test releases them, so how long
68
+ * the host takes to answer is something the test states rather than waits for.
69
+ */
70
+ function newDeferredManager() {
71
+ const releases: ((observation: HostObservation) => void)[] = [];
72
+ const manager = new WatchSessionManager({
73
+ now: () => nowMs,
74
+ observe: () =>
75
+ new Promise<HostObservation>((resolve) => {
76
+ releases.push(resolve);
77
+ }),
78
+ });
79
+ return { manager, releases };
80
+ }
81
+
82
+ function start(manager: WatchSessionManager) {
83
+ const result = manager.start({ sourceActorPrincipalId: PRINCIPAL_ID });
84
+ if (result.status !== "started") {
85
+ throw new Error(`Expected a started session, got ${result.status}`);
86
+ }
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Let every already-resolved promise in an observation's chain settle. Fake
92
+ * timers stop the clock but not the microtask queue, so the work an expired
93
+ * timer kicks off lands here.
94
+ */
95
+ async function settle(): Promise<void> {
96
+ for (let i = 0; i < 20; i += 1) {
97
+ await Promise.resolve();
98
+ }
99
+ }
100
+
101
+ /** Advance the session's clock and the timers armed against it together. */
102
+ async function elapse(ms: number): Promise<void> {
103
+ nowMs += ms;
104
+ jest.advanceTimersByTime(ms);
105
+ await settle();
106
+ }
107
+
108
+ beforeEach(() => {
109
+ jest.useFakeTimers();
110
+ nowMs = 1_000_000;
111
+ });
112
+
113
+ describe("watch session manager", () => {
114
+ test("runs in a background conversation so the thread stays out of the sidebar", () => {
115
+ const { manager } = newManager();
116
+ const started = start(manager);
117
+
118
+ expect(getConversation(started.conversationId)?.conversationType).toBe(
119
+ "background",
120
+ );
121
+ expect(manager.isActive()).toBe(true);
122
+ expect(manager.isActive(started.conversationId)).toBe(true);
123
+ expect(manager.isActive(randomUUID())).toBe(false);
124
+
125
+ manager.stop();
126
+ });
127
+
128
+ test("refuses a second session while one is running", () => {
129
+ const { manager } = newManager();
130
+ const started = start(manager);
131
+
132
+ const second = manager.start({ sourceActorPrincipalId: PRINCIPAL_ID });
133
+ expect(second).toEqual({
134
+ status: "busy",
135
+ sessionId: started.sessionId,
136
+ conversationId: started.conversationId,
137
+ });
138
+
139
+ manager.stop();
140
+ expect(manager.start({ sourceActorPrincipalId: PRINCIPAL_ID }).status).toBe(
141
+ "started",
142
+ );
143
+ manager.stop();
144
+ });
145
+
146
+ test("fails closed without an actor principal to observe for", () => {
147
+ const { manager, calls } = newManager();
148
+
149
+ const result = manager.start({
150
+ sourceActorPrincipalId: undefined as unknown as string,
151
+ });
152
+
153
+ expect(result.status).toBe("failed");
154
+ expect(manager.isActive()).toBe(false);
155
+ expect(calls).toHaveLength(0);
156
+ });
157
+
158
+ test("refuses a conversation id whose row does not exist", () => {
159
+ const { manager } = newManager();
160
+
161
+ const result = manager.start({
162
+ sourceActorPrincipalId: PRINCIPAL_ID,
163
+ conversationId: randomUUID(),
164
+ });
165
+
166
+ expect(result.status).toBe("failed");
167
+ expect(manager.isActive()).toBe(false);
168
+ });
169
+
170
+ test("collapses a burst of narration finals into one observation", async () => {
171
+ const { manager, calls } = newManager();
172
+ start(manager);
173
+
174
+ for (let i = 0; i < 5; i += 1) {
175
+ await manager.handleNarrationFinal(`burst ${i}`);
176
+ await elapse(400);
177
+ }
178
+
179
+ expect(calls).toHaveLength(1);
180
+
181
+ // The gap reopens once the minimum interval has passed.
182
+ await elapse(4_000);
183
+ await manager.handleNarrationFinal("after the floor");
184
+ expect(calls).toHaveLength(2);
185
+
186
+ manager.stop();
187
+ });
188
+
189
+ test("observes the screen the session opens on", async () => {
190
+ const { manager, calls } = newManager();
191
+ const started = start(manager);
192
+
193
+ // Before a word is spoken. A demonstration starts with the user already
194
+ // somewhere, and where that is decides whether the first narrated step is
195
+ // navigating there or working there.
196
+ expect(calls).toHaveLength(1);
197
+ await settle();
198
+ expect(renderWatchTimeline(started.sessionId).totalEntries).toBe(1);
199
+
200
+ manager.stop();
201
+ });
202
+
203
+ test("observes on speech onset rather than waiting for the sentence to land", async () => {
204
+ const { manager, calls } = newManager();
205
+ start(manager);
206
+ await settle();
207
+ await elapse(6_000);
208
+
209
+ await manager.handleNarrationStart();
210
+
211
+ expect(calls).toHaveLength(2);
212
+
213
+ manager.stop();
214
+ });
215
+
216
+ test("files no entry for speech onset, since the final files the narration", async () => {
217
+ const { manager } = newManager();
218
+ const started = start(manager);
219
+ await settle();
220
+ await elapse(6_000);
221
+
222
+ await manager.handleNarrationStart();
223
+ await manager.handleNarrationFinal("now I drag it to the Trash");
224
+
225
+ // The opening observation, the onset's observation, and one narration.
226
+ // An entry at onset would make a second, emptier record of one utterance.
227
+ const summary = manager.stop();
228
+ expect(summary?.entryCount).toBe(3);
229
+ expect(renderWatchTimeline(started.sessionId).totalEntries).toBe(3);
230
+ });
231
+
232
+ test("holds speech onset to the same floor as a final", async () => {
233
+ const { manager, calls } = newManager();
234
+ start(manager);
235
+ await settle();
236
+
237
+ // Onset lands inside the interval the opening observation just spent.
238
+ await elapse(1_000);
239
+ await manager.handleNarrationStart();
240
+ expect(calls).toHaveLength(1);
241
+
242
+ await elapse(4_000);
243
+ await manager.handleNarrationStart();
244
+ expect(calls).toHaveLength(2);
245
+
246
+ manager.stop();
247
+ });
248
+
249
+ test("ignores speech onset when no session is running", async () => {
250
+ const { manager, calls } = newManager();
251
+
252
+ await manager.handleNarrationStart();
253
+
254
+ expect(calls).toHaveLength(0);
255
+ expect(manager.isActive()).toBe(false);
256
+ });
257
+
258
+ test("observes four times across a 60s session of ten narration finals", async () => {
259
+ const { manager, calls } = newManager();
260
+ const started = start(manager);
261
+
262
+ // Three clusters of three finals plus a closing one, the shape of someone
263
+ // describing a step, working through it, then describing the next.
264
+ const finalsAtMs = [
265
+ 0, 500, 1_000, 20_000, 20_500, 21_000, 40_000, 40_500, 41_000, 59_000,
266
+ ];
267
+ let elapsedMs = 0;
268
+ for (const atMs of finalsAtMs) {
269
+ await elapse(atMs - elapsedMs);
270
+ elapsedMs = atMs;
271
+ await manager.handleNarrationFinal(`step at ${atMs}`);
272
+ }
273
+
274
+ expect(calls).toHaveLength(4);
275
+
276
+ const summary = manager.stop();
277
+ expect(summary).not.toBeNull();
278
+ expect(summary?.sessionId).toBe(started.sessionId);
279
+ expect(summary?.conversationId).toBe(started.conversationId);
280
+ expect(summary?.entryCount).toBe(finalsAtMs.length + calls.length);
281
+ expect(summary?.durationMs).toBe(59_000);
282
+ });
283
+
284
+ test("keeps observing through a silent stretch of work", async () => {
285
+ const { manager, calls } = newManager();
286
+ const started = start(manager);
287
+
288
+ // The opening observation goes out with the session, before a word is
289
+ // spoken, so the ceiling's own ticks are the ones after it. Settled first
290
+ // because the ceiling is armed when a read finishes, not when it is sent.
291
+ expect(calls).toHaveLength(1);
292
+ await settle();
293
+
294
+ // Not a word spoken for the next three quarters of a minute.
295
+ await elapse(15_000);
296
+ expect(calls).toHaveLength(2);
297
+ await elapse(15_000);
298
+ expect(calls).toHaveLength(3);
299
+ await elapse(15_000);
300
+ expect(calls).toHaveLength(4);
301
+
302
+ const rendered = renderWatchTimeline(started.sessionId);
303
+ expect(rendered.totalEntries).toBe(4);
304
+
305
+ manager.stop();
306
+ });
307
+
308
+ test("survives repeated observation failures and still hands back a handle", async () => {
309
+ const { manager, calls } = newManager((call) =>
310
+ call <= 3
311
+ ? {
312
+ ok: false,
313
+ reason: "No connected client supports screen observation",
314
+ }
315
+ : richObservation(),
316
+ );
317
+ const started = start(manager);
318
+
319
+ for (let i = 0; i < 4; i += 1) {
320
+ await manager.handleNarrationFinal(`narration ${i}`);
321
+ await elapse(6_000);
322
+ }
323
+
324
+ expect(calls).toHaveLength(4);
325
+ const summary = manager.stop();
326
+ // Four narrations landed; only the fourth observation had anything to file.
327
+ expect(summary?.entryCount).toBe(5);
328
+ expect(renderWatchTimeline(started.sessionId).totalEntries).toBe(5);
329
+ });
330
+
331
+ test("stop is idempotent and ends the cadence", async () => {
332
+ const { manager, calls } = newManager();
333
+ start(manager);
334
+
335
+ await manager.handleNarrationFinal("something worth watching");
336
+ expect(calls).toHaveLength(1);
337
+
338
+ const first = manager.stop();
339
+ expect(first).not.toBeNull();
340
+ expect(manager.stop()).toBeNull();
341
+ expect(manager.isActive()).toBe(false);
342
+ expect(manager.activeSessionId).toBeNull();
343
+
344
+ // Neither narration nor the idle ceiling reaches a stopped session.
345
+ await manager.handleNarrationFinal("nobody is listening");
346
+ await elapse(90_000);
347
+ expect(calls).toHaveLength(1);
348
+ });
349
+
350
+ test("never runs two observations at once, and drops one that lands after the session ended", async () => {
351
+ const { manager, releases } = newDeferredManager();
352
+ const started = start(manager);
353
+
354
+ const first = manager.handleNarrationFinal("mid-observation");
355
+ await settle();
356
+ expect(releases).toHaveLength(1);
357
+
358
+ // The floor has passed but the first read has not come back, so this final
359
+ // records what was said and waits rather than stacking a second request.
360
+ await elapse(6_000);
361
+ const second = manager.handleNarrationFinal("still talking");
362
+ await settle();
363
+ expect(releases).toHaveLength(1);
364
+
365
+ const summary = manager.stop();
366
+ expect(summary?.entryCount).toBe(2);
367
+
368
+ for (const release of releases) {
369
+ release(richObservation());
370
+ }
371
+ await Promise.all([first, second]);
372
+ await settle();
373
+
374
+ expect(renderWatchTimeline(started.sessionId).totalEntries).toBe(2);
375
+ });
376
+
377
+ test("a slow read spends the interval it belongs to rather than extending it", async () => {
378
+ const { manager, releases } = newDeferredManager();
379
+ start(manager);
380
+
381
+ // The session's opening read, dispatched with the session itself. Nothing
382
+ // arms the ceiling while it is outstanding, so silence adds no second one.
383
+ expect(releases).toHaveLength(1);
384
+ await elapse(8_000);
385
+ expect(releases).toHaveLength(1);
386
+
387
+ // The host takes 8s to answer, most of the 10s the session allows it.
388
+ releases[0](richObservation());
389
+ await settle();
390
+
391
+ // That read went out at t=0, so its successor is due at t=15s: 7s from
392
+ // here, not the full 15s a fresh interval measured from the answer would
393
+ // give it.
394
+ await elapse(6_999);
395
+ expect(releases).toHaveLength(1);
396
+ await elapse(1);
397
+ expect(releases).toHaveLength(2);
398
+
399
+ manager.stop();
400
+ });
401
+
402
+ test("a ceiling tick that lands mid-read is deferred, not dropped", async () => {
403
+ const { manager, releases } = newDeferredManager();
404
+ start(manager);
405
+
406
+ const narration = manager.handleNarrationFinal("starting the long one");
407
+ await settle();
408
+ expect(releases).toHaveLength(1);
409
+
410
+ // The ceiling comes due while that read is still out. It stacks no second
411
+ // request, and the deadline it belongs to is already spent.
412
+ await elapse(15_000);
413
+ expect(releases).toHaveLength(1);
414
+
415
+ releases[0](richObservation());
416
+ await narration;
417
+ await settle();
418
+ await elapse(1);
419
+ expect(releases).toHaveLength(2);
420
+
421
+ manager.stop();
422
+ });
423
+
424
+ /**
425
+ * The observation listener, which is what a client turns into "your screen
426
+ * was just read". It is only worth anything if it is exactly as true as that
427
+ * sentence, so every case here is a way for a read not to happen.
428
+ */
429
+ describe("observation listener", () => {
430
+ /** Start a session with a listener, and count what it hears. */
431
+ function startWithListener(
432
+ manager: WatchSessionManager,
433
+ onObservation: () => void = () => undefined,
434
+ ) {
435
+ const result = manager.start({
436
+ sourceActorPrincipalId: PRINCIPAL_ID,
437
+ onObservation,
438
+ });
439
+ if (result.status !== "started") {
440
+ throw new Error(`Expected a started session, got ${result.status}`);
441
+ }
442
+ return result;
443
+ }
444
+
445
+ test("fires once for every read that reached the timeline", async () => {
446
+ const { manager, calls } = newManager();
447
+ let heard = 0;
448
+ startWithListener(manager, () => {
449
+ heard += 1;
450
+ });
451
+ await settle();
452
+
453
+ // The session's opening read.
454
+ expect(calls).toHaveLength(1);
455
+ expect(heard).toBe(1);
456
+
457
+ await elapse(6_000);
458
+ await manager.handleNarrationFinal("dragging it into the folder");
459
+ expect(calls).toHaveLength(2);
460
+ expect(heard).toBe(2);
461
+
462
+ manager.stop();
463
+ });
464
+
465
+ test("stays silent when the host could not serve the read", async () => {
466
+ // Every failure arrives this way, a timeout included: `observeHostScreen`
467
+ // resolves `{ ok: false }` rather than throwing.
468
+ const { manager } = newManager(() => ({
469
+ ok: false,
470
+ reason: "No connected client supports screen observation",
471
+ timedOut: true,
472
+ }));
473
+ let heard = 0;
474
+ startWithListener(manager, () => {
475
+ heard += 1;
476
+ });
477
+ await settle();
478
+ await elapse(6_000);
479
+ await manager.handleNarrationFinal("nobody is reading this screen");
480
+
481
+ expect(heard).toBe(0);
482
+
483
+ manager.stop();
484
+ });
485
+
486
+ test("stays silent when the read came back carrying nothing", async () => {
487
+ // `ok` and useless: no tree, no diff, no frame. The store refuses it, so
488
+ // there is no record of the screen and nothing to confirm.
489
+ const { manager } = newManager(() => ({ ok: true }));
490
+ let heard = 0;
491
+ const started = startWithListener(manager, () => {
492
+ heard += 1;
493
+ });
494
+ await settle();
495
+
496
+ expect(heard).toBe(0);
497
+ expect(renderWatchTimeline(started.sessionId).totalEntries).toBe(0);
498
+
499
+ manager.stop();
500
+ });
501
+
502
+ test("stays silent for a read the session ended underneath", async () => {
503
+ const { manager, releases } = newDeferredManager();
504
+ let heard = 0;
505
+ startWithListener(manager, () => {
506
+ heard += 1;
507
+ });
508
+ await settle();
509
+ expect(releases).toHaveLength(1);
510
+
511
+ // The read is still out when the session ends. It answers to nobody: the
512
+ // timeline it would have joined belongs to a session that is over.
513
+ manager.stop();
514
+ releases[0]!(richObservation());
515
+ await settle();
516
+
517
+ expect(heard).toBe(0);
518
+ });
519
+
520
+ test("drops the listener with the session that carried it", async () => {
521
+ const { manager } = newManager();
522
+ let heard = 0;
523
+ startWithListener(manager, () => {
524
+ heard += 1;
525
+ });
526
+ await settle();
527
+ expect(heard).toBe(1);
528
+ manager.stop();
529
+
530
+ // A second session, started with no listener of its own. The first
531
+ // session's listener is gone with the first session rather than left
532
+ // reporting captures it has no claim on.
533
+ start(manager);
534
+ await settle();
535
+ expect(heard).toBe(1);
536
+
537
+ manager.stop();
538
+ });
539
+ });
540
+
541
+ describe("screenshot escalation", () => {
542
+ async function observeOnce(respond: () => HostObservation) {
543
+ const { manager } = newManager(respond);
544
+ const started = start(manager);
545
+ await manager.handleNarrationFinal("look at this");
546
+ manager.stop();
547
+ return renderWatchTimeline(started.sessionId);
548
+ }
549
+
550
+ test("keeps no frame when the accessibility tree carried the screen", async () => {
551
+ const rendered = await observeOnce(richObservation);
552
+ expect(rendered.totalEntries).toBe(2);
553
+ expect(rendered.screenshotEntryIds).toHaveLength(0);
554
+ });
555
+
556
+ test("keeps a frame when the observation has no accessibility tree", async () => {
557
+ const rendered = await observeOnce(treelessObservation);
558
+ expect(rendered.screenshotEntryIds).toHaveLength(1);
559
+ });
560
+
561
+ test("keeps a frame when the diff reports a whole-window replacement", async () => {
562
+ const rendered = await observeOnce(navigatedObservation);
563
+ expect(rendered.screenshotEntryIds).toHaveLength(1);
564
+ });
565
+ });
566
+ });