@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,261 @@
1
+ /**
2
+ * Reachability probe for the public URL a tunnel exposes.
3
+ *
4
+ * A tunnel's public URL is recorded in the workspace config, but a recorded
5
+ * URL says nothing about whether anything still answers on it: the tunnel
6
+ * process can die, the nginx edge can stay up while the gateway behind it is
7
+ * down, and a URL can end up fronting a different assistant entirely. The
8
+ * probe asks the edge two questions at once, and the two answers carry very
9
+ * different weight:
10
+ *
11
+ * - `GET /healthz` is the liveness verdict. Every ingress shape answers it:
12
+ * the nginx edge proxies it to the gateway, and a tunnel pointed straight
13
+ * at the gateway port serves it from the gateway itself. Anything but a
14
+ * 2xx means the URL is not usable at all, which is `unreachable`.
15
+ * - `GET /assistant/__config` decides whether that live edge is one a device
16
+ * can pair against. Only the nginx remote-web SPA edge serves it
17
+ * (`buildRemoteWebIngressLocations` in `cli/src/lib/nginx-ingress.ts`),
18
+ * and that is the same edge serving `/assistant/pair`, so an edge that
19
+ * answers the config request with anything but that edge's own config is
20
+ * an edge whose pair URL would 404. That is `unpairable`: alive, and no
21
+ * use to a card whose whole job is pairing. A tunnel pointed straight at
22
+ * the gateway, a bring-your-own HTTPS front that only proxies the gateway,
23
+ * and a catch-all that answers every path with some JSON all land there.
24
+ * A config request that gets no answer at all, because it lost the shared
25
+ * deadline or the connection dropped under it, proves nothing either way,
26
+ * and the live `/healthz` stands.
27
+ *
28
+ * The served config also carries the assistant's id, which is what separates
29
+ * `healthy` from `foreign`.
30
+ *
31
+ * Neither path is denylisted, so both are publicly reachable.
32
+ *
33
+ * The module takes a URL and an expected id and returns a verdict: it reads no
34
+ * config and knows nothing about workspace paths, which is what lets callers
35
+ * probe an arbitrary URL and lets tests run without network access.
36
+ */
37
+
38
+ import {
39
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
40
+ trimmedNonEmptyString,
41
+ } from "@vellumai/service-contracts/ingress";
42
+
43
+ import { isPlainObject } from "../util/object.js";
44
+ import { truncate } from "../util/truncate.js";
45
+
46
+ /**
47
+ * Verdict for one probe.
48
+ *
49
+ * `unpairable` means the edge answered the config request with something that
50
+ * is not the pairing edge's config, so it is alive without fronting the
51
+ * pairing app.
52
+ * `foreign` means the edge positively identified itself as a different
53
+ * assistant. Any case where either id is unknown is `healthy`: a config
54
+ * served by a CLI that predates the id, or a URL recorded before the id was
55
+ * saved, is version skew, not someone else's assistant.
56
+ */
57
+ export type TunnelProbeResult =
58
+ | { kind: "healthy"; assistantId?: string; assistantName?: string }
59
+ | { kind: "unreachable"; detail: string }
60
+ | { kind: "unpairable"; detail?: string }
61
+ | { kind: "foreign"; assistantId?: string; assistantName?: string };
62
+
63
+ const DEFAULT_TIMEOUT_MS = 4_000;
64
+
65
+ /** Placeholder swapped in for the probed URL so `detail` stays URL-free. */
66
+ const REDACTED_URL = "<url>";
67
+
68
+ /** `detail` is rendered inline in a settings card, so it has to stay short. */
69
+ const MAX_DETAIL_LENGTH = 120;
70
+
71
+ /** Bound on the `cause` walk, so a self-referential chain cannot spin. */
72
+ const MAX_CAUSE_DEPTH = 5;
73
+
74
+ /** Wrappers fetch layers raise when the real reason sits one `cause` down. */
75
+ const GENERIC_FETCH_MESSAGES = new Set(["fetch failed", "failed to fetch"]);
76
+
77
+ /** Reported when the config path answers with a body that is not a config. */
78
+ const UNREADABLE_CONFIG_DETAIL = "no assistant config served";
79
+
80
+ /** Reported when a served body parses but is not the pairing edge's config. */
81
+ const UNMARKED_CONFIG_DETAIL = "not a pairing edge config";
82
+
83
+ /**
84
+ * Stamped into the served config by the nginx pairing edge and nothing else
85
+ * (`remoteWebIngressConfig` in `cli/src/lib/nginx-ingress.ts`), which makes it
86
+ * the marker that separates that edge from any other JSON a live URL serves.
87
+ */
88
+ const PAIRING_EDGE_MODE = "remote-gateway";
89
+
90
+ export async function probeTunnel(args: {
91
+ publicBaseUrl: string;
92
+ /** Id recorded when this URL was saved; omit to skip the identity check. */
93
+ expectedAssistantId?: string;
94
+ timeoutMs?: number;
95
+ fetchImpl?: typeof fetch;
96
+ }): Promise<TunnelProbeResult> {
97
+ if (args.publicBaseUrl.trim().length === 0) {
98
+ return { kind: "unreachable", detail: "no public base URL" };
99
+ }
100
+ // The same validation the config writers apply, so a value they would have
101
+ // rejected is named as malformed rather than handed to `fetch` and reported
102
+ // through whatever string that layer happens to produce.
103
+ const base = normalizeHttpPublicBaseUrlWithoutTrailingSlash(
104
+ args.publicBaseUrl,
105
+ );
106
+ if (base === undefined) {
107
+ return { kind: "unreachable", detail: "not an http(s) URL" };
108
+ }
109
+
110
+ const fetchImpl = args.fetchImpl ?? globalThis.fetch.bind(globalThis);
111
+ // One deadline covers both requests: only an answered config request can
112
+ // demote a live edge, so a slow edge that answers `/healthz` and loses the
113
+ // config request to the same deadline keeps its liveness verdict.
114
+ const signal = AbortSignal.timeout(args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
115
+ const [health, config] = await Promise.allSettled([
116
+ fetchImpl(`${base}/healthz`, { signal }),
117
+ fetchImpl(`${base}/assistant/__config`, { signal }),
118
+ ]);
119
+ const configResponse =
120
+ config.status === "fulfilled" ? config.value : undefined;
121
+
122
+ if (health.status === "rejected") {
123
+ await discardBody(configResponse);
124
+ return unreachable(describeError(health.reason), base);
125
+ }
126
+ // Liveness is the status line; the health body is never read.
127
+ await discardBody(health.value);
128
+ if (!health.value.ok) {
129
+ await discardBody(configResponse);
130
+ return unreachable(`HTTP ${health.value.status}`, base);
131
+ }
132
+
133
+ const served = await readServedConfig(configResponse);
134
+ if (served.kind === "notServed") {
135
+ return { kind: "unpairable", detail: served.detail };
136
+ }
137
+
138
+ const identity = served.kind === "served" ? served.config : {};
139
+ const expected = trimmedNonEmptyString(args.expectedAssistantId);
140
+ const isForeign =
141
+ expected !== undefined &&
142
+ identity.assistantId !== undefined &&
143
+ identity.assistantId !== expected;
144
+
145
+ return { kind: isForeign ? "foreign" : "healthy", ...identity };
146
+ }
147
+
148
+ /**
149
+ * Fetch layers vary in how much of the request they echo into an error
150
+ * message, and the caller already knows which URL it asked about.
151
+ */
152
+ function unreachable(detail: string, base: string): TunnelProbeResult {
153
+ return {
154
+ kind: "unreachable",
155
+ detail: truncate(detail.replaceAll(base, REDACTED_URL), MAX_DETAIL_LENGTH),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * An unread fetch body pins its socket until GC under Bun and undici, and the
161
+ * probe runs on every settings mount, resume, and manual refresh.
162
+ */
163
+ async function discardBody(response: Response | undefined): Promise<void> {
164
+ try {
165
+ await response?.body?.cancel();
166
+ } catch {
167
+ /* best effort */
168
+ }
169
+ }
170
+
171
+ function isTimeoutError(error: unknown): boolean {
172
+ return (
173
+ isPlainObject(error) &&
174
+ (error.name === "TimeoutError" || error.name === "AbortError")
175
+ );
176
+ }
177
+
178
+ function isGenericFetchMessage(message: string): boolean {
179
+ return GENERIC_FETCH_MESSAGES.has(message.toLowerCase());
180
+ }
181
+
182
+ /**
183
+ * A dead tunnel surfaces as a generic wrapper ("fetch failed") whose `cause`
184
+ * carries the reason worth reading (`ECONNREFUSED`, `ENOTFOUND`), so walk the
185
+ * chain for a code, then for the first message that says something.
186
+ */
187
+ function describeError(error: unknown): string {
188
+ let message: string | undefined;
189
+ let current: unknown = error;
190
+
191
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) {
192
+ if (!isPlainObject(current)) {
193
+ break;
194
+ }
195
+ if (isTimeoutError(current)) {
196
+ return "timeout";
197
+ }
198
+ const code = trimmedNonEmptyString(current.code);
199
+ if (code !== undefined) {
200
+ return code;
201
+ }
202
+ // A wrapper only holds the slot until a deeper level fills it.
203
+ if (message === undefined || isGenericFetchMessage(message)) {
204
+ message = trimmedNonEmptyString(current.message) ?? message;
205
+ }
206
+ current = current.cause;
207
+ }
208
+
209
+ return message ?? String(error);
210
+ }
211
+
212
+ interface ServedEdgeConfig {
213
+ assistantId?: string;
214
+ assistantName?: string;
215
+ }
216
+
217
+ /**
218
+ * What the config request settled: the identity the pairing edge reports, a
219
+ * positive "whatever is here does not serve the pairing app", or nothing.
220
+ */
221
+ type ServedConfigReading =
222
+ | { kind: "unanswered" }
223
+ | { kind: "notServed"; detail: string }
224
+ | { kind: "served"; config: ServedEdgeConfig };
225
+
226
+ /**
227
+ * A 2xx JSON object carrying the pairing edge's marker is that edge
228
+ * identifying itself, any other answer is an edge that does not serve the
229
+ * pairing app, and no answer is no evidence.
230
+ */
231
+ async function readServedConfig(
232
+ response: Response | undefined,
233
+ ): Promise<ServedConfigReading> {
234
+ if (response === undefined) {
235
+ return { kind: "unanswered" };
236
+ }
237
+ if (!response.ok) {
238
+ await discardBody(response);
239
+ return { kind: "notServed", detail: `HTTP ${response.status}` };
240
+ }
241
+ let detail = UNREADABLE_CONFIG_DETAIL;
242
+ try {
243
+ const body: unknown = await response.json();
244
+ if (isPlainObject(body)) {
245
+ if (body.mode === PAIRING_EDGE_MODE) {
246
+ return {
247
+ kind: "served",
248
+ config: {
249
+ assistantId: trimmedNonEmptyString(body.assistantId),
250
+ assistantName: trimmedNonEmptyString(body.assistantName),
251
+ },
252
+ };
253
+ }
254
+ detail = UNMARKED_CONFIG_DETAIL;
255
+ }
256
+ } catch {
257
+ /* an unparseable body is an edge answering with something else */
258
+ }
259
+ await discardBody(response);
260
+ return { kind: "notServed", detail };
261
+ }
@@ -115,6 +115,31 @@ describe("createLiveVoiceConnection", () => {
115
115
  ]);
116
116
  });
117
117
 
118
+ test("drops a pending start when its transport closes mid-wait", async () => {
119
+ const { sessions } = installFakeManager((_session, ctx) =>
120
+ ctx.sessionId === "session-1"
121
+ ? { close: mock(() => new Promise<void>(() => {})) }
122
+ : {},
123
+ );
124
+ const first = createConnection();
125
+ const second = createConnection();
126
+
127
+ await first.connection.handleMessage(START_MESSAGE);
128
+ // The first transport goes away and its session begins a teardown that
129
+ // never finishes, so the next start has to wait for the slot.
130
+ first.connection.release();
131
+
132
+ const pending = second.connection.handleMessage(START_MESSAGE);
133
+ await Promise.resolve();
134
+ second.connection.release();
135
+ await pending;
136
+
137
+ // No session was built for the socket that is already gone.
138
+ expect(sessions).toHaveLength(1);
139
+ expect(second.connection.sessionId).toBeUndefined();
140
+ expect(second.frames).toEqual([]);
141
+ });
142
+
118
143
  test("assigns monotonically increasing sequence numbers", async () => {
119
144
  installFakeManager();
120
145
  const { connection, frames } = createConnection();
@@ -410,7 +410,10 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
410
410
  await waitFor(() => turnCalls.length === 1);
411
411
 
412
412
  const metricsFrame = await waitForTurnMetrics(frames);
413
- expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(250);
413
+ // Date.now() vs setTimeout can undershoot the sleep by a millisecond on
414
+ // a loaded runner. The assertion is that the speech-stop anchor saw the
415
+ // pause, not that the timer is exact.
416
+ expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(240);
414
417
 
415
418
  await session.close("client_end");
416
419
  });
@@ -435,7 +438,10 @@ describe("LiveVoiceSession Flux end-of-turn", () => {
435
438
  // the two arms one population rather than two.
436
439
  const metricsFrame = await waitForTurnMetrics(frames);
437
440
  expect(metricsFrame.endpointDecisionSource).toBeUndefined();
438
- expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(40);
441
+ // The silence timer is 40 ms. CI clock jitter can report 39. The
442
+ // assertion is that the front-door path recorded a silence-scale
443
+ // latency, not that setTimeout fired on the exact millisecond.
444
+ expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(30);
439
445
 
440
446
  await session.close("client_end");
441
447
  });
@@ -6,6 +6,7 @@ import {
6
6
  type LiveVoiceSessionFactoryContext,
7
7
  LiveVoiceSessionManager,
8
8
  LiveVoiceSessionStartupError,
9
+ type LiveVoiceSlotEvent,
9
10
  } from "../live-voice-session-manager.js";
10
11
  import type {
11
12
  LiveVoiceClientFrame,
@@ -328,7 +329,7 @@ describe("LiveVoiceSessionManager", () => {
328
329
  expect(manager.activeSessionId).toBe("session-1");
329
330
  });
330
331
 
331
- test("holds the session lock until close completes", async () => {
332
+ test("makes a start that arrives mid-close wait for the slot", async () => {
332
333
  const sessions: TestSession[] = [];
333
334
  let resolveClose: (() => void) | undefined;
334
335
  const manager = new LiveVoiceSessionManager({
@@ -349,28 +350,28 @@ describe("LiveVoiceSessionManager", () => {
349
350
  });
350
351
  const first = createSink();
351
352
  const second = createSink();
352
- const third = createSink();
353
353
 
354
354
  await manager.startSession(START_FRAME, first.sink);
355
355
  const releasePromise = manager.releaseSession("session-1", "client_end");
356
- const concurrent = await manager.startSession(START_FRAME, second.sink);
356
+ // The reconnect: a fresh transport asking for a session while the previous
357
+ // one is still unwinding. It must not be told the dying session is active.
358
+ const concurrent = manager.startSession(START_FRAME, second.sink);
357
359
  const concurrentDispatch = await manager.handleClientFrame("session-1", {
358
360
  type: "interrupt",
359
361
  });
360
362
 
361
- expect(concurrent).toEqual({
362
- status: "busy",
363
- activeSessionId: "session-1",
364
- frame: { type: "busy", seq: 1, activeSessionId: "session-1" },
365
- });
366
363
  expect(concurrentDispatch).toEqual({ status: "not_found" });
364
+ // The lock still holds: no second session exists alongside the closing one.
367
365
  expect(sessions).toHaveLength(1);
366
+ expect(second.frames).toEqual([]);
368
367
 
369
368
  resolveClose?.();
370
369
  await releasePromise;
371
370
 
372
- const next = await manager.startSession(START_FRAME, third.sink);
373
- expect(next).toEqual({ status: "accepted", sessionId: "session-2" });
371
+ expect(await concurrent).toEqual({
372
+ status: "accepted",
373
+ sessionId: "session-2",
374
+ });
374
375
  expect(manager.activeSessionId).toBe("session-2");
375
376
  });
376
377
 
@@ -393,3 +394,204 @@ describe("LiveVoiceSessionManager", () => {
393
394
  }
394
395
  });
395
396
  });
397
+
398
+ /**
399
+ * Closure assurance (LUM-3440): the slot must come back on its own from every
400
+ * way a session can stop being usable, because the client whose transport
401
+ * died cannot ask for it back and a user who force-quits the app does not get
402
+ * a second chance either.
403
+ */
404
+ describe("LiveVoiceSessionManager slot reclamation", () => {
405
+ const SERVER_VAD_START = {
406
+ ...START_FRAME,
407
+ turnDetection: "server_vad",
408
+ } as const satisfies LiveVoiceClientStartFrame;
409
+
410
+ const sleep = (ms: number) =>
411
+ new Promise((resolve) => setTimeout(resolve, ms));
412
+
413
+ test("frees the slot when a session's close never finishes", async () => {
414
+ const events: LiveVoiceSlotEvent[] = [];
415
+ const sessions: TestSession[] = [];
416
+ const manager = new LiveVoiceSessionManager({
417
+ createSessionId: mock(() => `session-${sessions.length + 1}`),
418
+ closeTimeoutMs: 25,
419
+ createSession: () => {
420
+ const session = createTestSession({
421
+ // A teardown that hangs: the shape of a close awaiting a
422
+ // continuation delivery or a provider that never answers.
423
+ close: mock(() => new Promise<void>(() => {})),
424
+ });
425
+ sessions.push(session);
426
+ return session;
427
+ },
428
+ onSlotEvent: (event) => events.push(event),
429
+ });
430
+
431
+ await manager.startSession(START_FRAME, createSink().sink);
432
+ await manager.releaseSession("session-1", "websocket_close");
433
+
434
+ expect(manager.activeSessionId).toBeNull();
435
+ expect(events).toEqual([
436
+ {
437
+ kind: "close_timed_out",
438
+ sessionId: "session-1",
439
+ reason: "websocket_close",
440
+ timeoutMs: 25,
441
+ },
442
+ ]);
443
+
444
+ // The whole point: the next session starts instead of being told the
445
+ // wedged one is still active.
446
+ const next = await manager.startSession(START_FRAME, createSink().sink);
447
+ expect(next).toEqual({ status: "accepted", sessionId: "session-2" });
448
+ });
449
+
450
+ test("releases a server_vad session whose client goes silent", async () => {
451
+ const events: LiveVoiceSlotEvent[] = [];
452
+ const session = createTestSession();
453
+ const manager = new LiveVoiceSessionManager({
454
+ createSessionId: () => "session-1",
455
+ clientSilenceTimeoutMs: 30,
456
+ createSession: () => session,
457
+ onSlotEvent: (event) => events.push(event),
458
+ });
459
+
460
+ await manager.startSession(SERVER_VAD_START, createSink().sink);
461
+ expect(manager.activeSessionId).toBe("session-1");
462
+
463
+ await sleep(90);
464
+
465
+ expect(manager.activeSessionId).toBeNull();
466
+ expect(session.closeReasons).toEqual(["client_timeout"]);
467
+ expect(events).toEqual([
468
+ {
469
+ kind: "client_silence_timeout",
470
+ sessionId: "session-1",
471
+ timeoutMs: 30,
472
+ },
473
+ ]);
474
+ });
475
+
476
+ test("hangs up the transport it reclaimed the slot from", async () => {
477
+ const session = createTestSession();
478
+ const manager = new LiveVoiceSessionManager({
479
+ createSessionId: () => "session-1",
480
+ clientSilenceTimeoutMs: 30,
481
+ createSession: () => session,
482
+ });
483
+ const closeTransport = mock(() => {});
484
+ const { sink } = createSink();
485
+
486
+ await manager.startSession(SERVER_VAD_START, { ...sink, closeTransport });
487
+ await sleep(90);
488
+
489
+ // Reclaiming the slot alone would leave the client streaming into a
490
+ // session that no longer exists, and every socket out to it open.
491
+ expect(closeTransport).toHaveBeenCalledTimes(1);
492
+ expect(session.closeReasons).toEqual(["client_timeout"]);
493
+ });
494
+
495
+ test("inbound audio pushes the silence deadline out", async () => {
496
+ const session = createTestSession();
497
+ const manager = new LiveVoiceSessionManager({
498
+ createSessionId: () => "session-1",
499
+ clientSilenceTimeoutMs: 60,
500
+ createSession: () => session,
501
+ });
502
+
503
+ await manager.startSession(SERVER_VAD_START, createSink().sink);
504
+ await sleep(40);
505
+ await manager.handleBinaryAudio("session-1", new Uint8Array([1, 2, 3]));
506
+ // Past the original deadline: only the refresh keeps this session alive.
507
+ await sleep(40);
508
+
509
+ expect(manager.activeSessionId).toBe("session-1");
510
+ expect(session.closeReasons).toEqual([]);
511
+ });
512
+
513
+ test("leaves a manual session alone, its silence is not evidence", async () => {
514
+ const session = createTestSession();
515
+ const manager = new LiveVoiceSessionManager({
516
+ createSessionId: () => "session-1",
517
+ clientSilenceTimeoutMs: 20,
518
+ createSession: () => session,
519
+ });
520
+
521
+ await manager.startSession(
522
+ { ...START_FRAME, turnDetection: "manual" },
523
+ createSink().sink,
524
+ );
525
+ await sleep(70);
526
+
527
+ expect(manager.activeSessionId).toBe("session-1");
528
+ expect(session.closeReasons).toEqual([]);
529
+ });
530
+
531
+ test("abandons a start whose transport went away while it waited", async () => {
532
+ const sessions: TestSession[] = [];
533
+ const manager = new LiveVoiceSessionManager({
534
+ createSessionId: mock(() => `session-${sessions.length + 1}`),
535
+ createSession: () => {
536
+ const session = createTestSession({
537
+ close: mock(() => new Promise<void>(() => {})),
538
+ });
539
+ sessions.push(session);
540
+ return session;
541
+ },
542
+ });
543
+ const second = createSink();
544
+
545
+ await manager.startSession(START_FRAME, createSink().sink);
546
+ void manager.releaseSession("session-1", "transport_closed");
547
+
548
+ const abort = new AbortController();
549
+ const pending = manager.startSession(START_FRAME, second.sink, {
550
+ signal: abort.signal,
551
+ });
552
+ await sleep(0);
553
+ // The socket that asked gave up while the outgoing slot was still
554
+ // unwinding. Building its session anyway would hand the slot to nobody.
555
+ abort.abort();
556
+
557
+ expect(await pending).toEqual({ status: "aborted" });
558
+ expect(sessions).toHaveLength(1);
559
+ expect(second.frames).toEqual([]);
560
+ });
561
+
562
+ test("hangs up the transport when the slot is force-ended", async () => {
563
+ const session = createTestSession();
564
+ const manager = new LiveVoiceSessionManager({
565
+ createSessionId: () => "session-1",
566
+ createSession: () => session,
567
+ });
568
+ const closeTransport = mock(() => {});
569
+ const { sink } = createSink();
570
+
571
+ await manager.startSession(START_FRAME, { ...sink, closeTransport });
572
+ await manager.endActiveSession();
573
+
574
+ // Otherwise the client whose session was ended from elsewhere keeps
575
+ // showing an active call until it happens to send another frame.
576
+ expect(closeTransport).toHaveBeenCalledTimes(1);
577
+ expect(session.closeReasons).toEqual(["forced_end"]);
578
+ });
579
+
580
+ test("ends the active session on request, whoever holds it", async () => {
581
+ const session = createTestSession();
582
+ const manager = new LiveVoiceSessionManager({
583
+ createSessionId: () => "session-1",
584
+ createSession: () => session,
585
+ });
586
+
587
+ expect(await manager.endActiveSession()).toEqual({ released: false });
588
+
589
+ await manager.startSession(START_FRAME, createSink().sink);
590
+ expect(await manager.endActiveSession()).toEqual({
591
+ released: true,
592
+ sessionId: "session-1",
593
+ });
594
+ expect(session.closeReasons).toEqual(["forced_end"]);
595
+ expect(manager.activeSessionId).toBeNull();
596
+ });
597
+ });
@@ -147,9 +147,12 @@ describe("live-voice session telemetry", () => {
147
147
 
148
148
  await session.close("client_end");
149
149
 
150
+ // These sessions reach `ready` and are closed without ever sending audio,
151
+ // so they are silent by construction and the reason says which layer
152
+ // stopped short. See `telemetry/live-voice-funnel.ts`.
150
153
  expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
151
154
  sessionId: "session-123",
152
- screen: "ended_client_end",
155
+ screen: "ended_client_end:silent_no_audio",
153
156
  outcome: "completed",
154
157
  });
155
158
  });
@@ -164,7 +167,7 @@ describe("live-voice session telemetry", () => {
164
167
  // the reason on `screen` is what distinguishes a drop from a hangup.
165
168
  expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
166
169
  sessionId: "session-123",
167
- screen: "ended_websocket_close",
170
+ screen: "ended_websocket_close:silent_no_audio",
168
171
  outcome: "completed",
169
172
  });
170
173
  });