@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,149 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ listAppPins,
5
+ removeAppPin,
6
+ updateAppPin,
7
+ } from "../apps/app-pin-store.js";
8
+ import { getDb } from "../persistence/db-connection.js";
9
+ import { initializeDb } from "../persistence/db-init.js";
10
+ import { appPins } from "../persistence/schema/index.js";
11
+
12
+ await initializeDb();
13
+
14
+ function pin(appId: string): void {
15
+ updateAppPin(appId, { pinned: true });
16
+ }
17
+
18
+ function order(): string[] {
19
+ return listAppPins().map((entry) => entry.appId);
20
+ }
21
+
22
+ function positionOf(appId: string): number | undefined {
23
+ return listAppPins().find((entry) => entry.appId === appId)?.sortPosition;
24
+ }
25
+
26
+ beforeEach(() => {
27
+ getDb().delete(appPins).run();
28
+ });
29
+
30
+ describe("pinning", () => {
31
+ test("appends each new pin after the last", () => {
32
+ pin("a");
33
+ pin("b");
34
+ pin("c");
35
+
36
+ expect(order()).toEqual(["a", "b", "c"]);
37
+ });
38
+
39
+ test("pinning an already-pinned app leaves its position alone", () => {
40
+ pin("a");
41
+ pin("b");
42
+ const before = positionOf("a");
43
+
44
+ pin("a");
45
+
46
+ expect(positionOf("a")).toBe(before!);
47
+ expect(order()).toEqual(["a", "b"]);
48
+ });
49
+
50
+ test("reports the resulting pin, and null once unpinned", () => {
51
+ expect(updateAppPin("a", { pinned: true })).toMatchObject({ appId: "a" });
52
+ expect(updateAppPin("a", { pinned: false })).toBeNull();
53
+ });
54
+
55
+ test("an unpin with nothing pinned is a no-op, not a throw", () => {
56
+ expect(updateAppPin("ghost", { pinned: false })).toBeNull();
57
+ expect(listAppPins()).toEqual([]);
58
+ });
59
+ });
60
+
61
+ /*
62
+ * Positions are a fractional index, so removing one leaves the others exactly
63
+ * where they were. Asserted by value rather than by relative order, which would
64
+ * hold either way and would not catch a rewrite that renumbers the survivors.
65
+ */
66
+ describe("unpinning", () => {
67
+ test("leaves the surviving positions untouched", () => {
68
+ pin("a");
69
+ pin("b");
70
+ pin("c");
71
+ const positions = { a: positionOf("a"), c: positionOf("c") };
72
+
73
+ updateAppPin("b", { pinned: false });
74
+
75
+ expect(order()).toEqual(["a", "c"]);
76
+ expect(positionOf("a")).toBe(positions.a!);
77
+ expect(positionOf("c")).toBe(positions.c!);
78
+ });
79
+
80
+ test("a pin taken after an unpin lands last, not on a survivor", () => {
81
+ pin("a");
82
+ pin("b");
83
+ updateAppPin("a", { pinned: false });
84
+
85
+ pin("c");
86
+
87
+ expect(order()).toEqual(["b", "c"]);
88
+ expect(positionOf("c")).toBeGreaterThan(positionOf("b")!);
89
+ });
90
+
91
+ test("removeAppPin drops the pin", () => {
92
+ pin("a");
93
+ pin("b");
94
+
95
+ removeAppPin("a");
96
+
97
+ expect(order()).toEqual(["b"]);
98
+ });
99
+
100
+ test("removeAppPin for an unpinned app changes nothing", () => {
101
+ pin("a");
102
+
103
+ removeAppPin("ghost");
104
+
105
+ expect(order()).toEqual(["a"]);
106
+ });
107
+ });
108
+
109
+ describe("colour", () => {
110
+ test("sets and clears without disturbing the position", () => {
111
+ pin("a");
112
+ pin("b");
113
+ const before = positionOf("b");
114
+
115
+ updateAppPin("b", { color: "teal" });
116
+ expect(listAppPins()[1]?.color).toBe("teal");
117
+ expect(positionOf("b")).toBe(before!);
118
+
119
+ updateAppPin("b", { color: null });
120
+ expect(listAppPins()[1]?.color).toBeUndefined();
121
+ expect(positionOf("b")).toBe(before!);
122
+ });
123
+
124
+ test("survives a later pin call that says nothing about colour", () => {
125
+ pin("a");
126
+ updateAppPin("a", { color: "teal" });
127
+
128
+ updateAppPin("a", { pinned: true });
129
+
130
+ expect(listAppPins()[0]?.color).toBe("teal");
131
+ });
132
+
133
+ /* Otherwise a colour set on an app the user just unpinned would put the pin
134
+ back, and the sidebar would grow a row nobody asked for. */
135
+ test("a colour for an unpinned app creates no pin", () => {
136
+ expect(updateAppPin("ghost", { color: "teal" })).toBeNull();
137
+ expect(listAppPins()).toEqual([]);
138
+ });
139
+
140
+ test("unpinning takes the colour with it", () => {
141
+ pin("a");
142
+ updateAppPin("a", { color: "teal" });
143
+
144
+ updateAppPin("a", { pinned: false });
145
+ pin("a");
146
+
147
+ expect(listAppPins()[0]?.color).toBeUndefined();
148
+ });
149
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Tests for `assistant/src/runtime/routes/channel-availability-routes.ts`.
3
3
  *
4
- * The handler returns a fixed base list (`slack`, `telegram`, `phone`) and
4
+ * The handler returns a fixed base list (`slack`, `telegram`, `discord`,
5
+ * `phone`) and
5
6
  * appends `email` when the platform reports at least one registered email
6
7
  * address for this assistant. Platform failures fall back to base-only.
7
8
  *
@@ -90,6 +91,7 @@ describe("channels/available", () => {
90
91
  expect(result.channels.map((c) => c.id)).toEqual([
91
92
  "slack",
92
93
  "telegram",
94
+ "discord",
93
95
  "phone",
94
96
  ]);
95
97
  });
@@ -106,6 +108,7 @@ describe("channels/available", () => {
106
108
  expect(result.channels.map((c) => c.id)).toEqual([
107
109
  "slack",
108
110
  "telegram",
111
+ "discord",
109
112
  "phone",
110
113
  "email",
111
114
  ]);
@@ -135,6 +138,7 @@ describe("channels/available", () => {
135
138
  expect(result.channels.map((c) => c.id)).toEqual([
136
139
  "slack",
137
140
  "telegram",
141
+ "discord",
138
142
  "phone",
139
143
  ]);
140
144
  });
@@ -147,6 +151,7 @@ describe("channels/available", () => {
147
151
  expect(result.channels.map((c) => c.id)).toEqual([
148
152
  "slack",
149
153
  "telegram",
154
+ "discord",
150
155
  "phone",
151
156
  ]);
152
157
  });
@@ -159,6 +164,7 @@ describe("channels/available", () => {
159
164
  expect(result.channels.map((c) => c.id)).toEqual([
160
165
  "slack",
161
166
  "telegram",
167
+ "discord",
162
168
  "phone",
163
169
  ]);
164
170
  });
@@ -216,4 +222,20 @@ describe("channels/available", () => {
216
222
  expect(ch!.setupMessages.contact.length).toBeGreaterThan(0);
217
223
  }
218
224
  });
225
+
226
+ test("offers Discord for verification, like the other text channels", async () => {
227
+ const result = (await handler({})) as HandlerResult;
228
+
229
+ const discord = result.channels.find((c) => c.id === "discord");
230
+ // Availability is what a client reads to decide whether to offer setup at
231
+ // all, so a channel absent here can never be verified however complete its
232
+ // transport is.
233
+ expect(discord).toBeDefined();
234
+ expect(discord!.supportsVerification).toBe(true);
235
+ expect(discord!.source).toBe("default");
236
+ expect(discord!.setupMessages.guardian).toContain("Discord");
237
+ // This field carries a Lucide name, which a brand mark is not. A client
238
+ // with room for one picks it by channel; every client can draw this.
239
+ expect(discord!.icon).toBe("hash");
240
+ });
219
241
  });
@@ -0,0 +1,231 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { credentialKey } from "../security/credential-key.js";
4
+
5
+ let mockSecureKeys: Record<string, string>;
6
+
7
+ mock.module("../security/secure-keys.js", () => ({
8
+ getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
9
+ setSecureKeyAsync: async (key: string, value: string) => {
10
+ mockSecureKeys[key] = value;
11
+ return true;
12
+ },
13
+ deleteSecureKeyAsync: async (key: string) => {
14
+ delete mockSecureKeys[key];
15
+ return true;
16
+ },
17
+ }));
18
+
19
+ mock.module("../calls/twilio-rest.js", () => ({
20
+ hasTwilioCredentials: () => false,
21
+ }));
22
+
23
+ mock.module("../calls/twilio-config.js", () => ({
24
+ resolveTwilioPhoneNumber: () => undefined,
25
+ }));
26
+
27
+ mock.module("./channel-invite-transports/whatsapp.js", () => ({
28
+ resolveWhatsAppDisplayNumber: () => undefined,
29
+ }));
30
+
31
+ type SocketHealth = {
32
+ channel: string;
33
+ status: "connected" | "disconnected" | "not_configured" | "unsupported";
34
+ lastLivenessAt?: number;
35
+ };
36
+
37
+ /**
38
+ * Every channel the socket-health reader was asked about, in order.
39
+ *
40
+ * The reader takes the channel as an argument and one function serves every
41
+ * socket-backed channel, so a probe naming the wrong one reports a different
42
+ * channel's connection as its own and no type notices.
43
+ */
44
+ let askedChannels: string[];
45
+ let socketHealth: SocketHealth | Error;
46
+
47
+ /** How many channels the gateway reports on Discord's allow-list. */
48
+ let admittedChannelCount: number | Error;
49
+
50
+ mock.module("../channels/gateway-discord-admission.js", () => ({
51
+ readDiscordAdmission: async () => {
52
+ if (admittedChannelCount instanceof Error) {
53
+ throw admittedChannelCount;
54
+ }
55
+ return { admittedChannelCount };
56
+ },
57
+ }));
58
+
59
+ mock.module("../channels/gateway-channel-socket-health.js", () => ({
60
+ readChannelSocketHealth: async (channel: string) => {
61
+ askedChannels.push(channel);
62
+ if (socketHealth instanceof Error) {
63
+ throw socketHealth;
64
+ }
65
+ return socketHealth;
66
+ },
67
+ }));
68
+
69
+ beforeEach(() => {
70
+ mockSecureKeys = {};
71
+ askedChannels = [];
72
+ socketHealth = { channel: "discord", status: "connected" };
73
+ // The default install state: fail-closed and empty, admitting nothing until
74
+ // someone lists channel ids. Tests that need admission say so.
75
+ admittedChannelCount = 0;
76
+ });
77
+
78
+ async function runDiscordProbe() {
79
+ const { createReadinessService } =
80
+ await import("../runtime/channel-readiness-service.js");
81
+ return createReadinessService().getReadiness("discord", false);
82
+ }
83
+
84
+ function findCheck(
85
+ snapshot: Awaited<ReturnType<typeof runDiscordProbe>>[number],
86
+ name: string,
87
+ ) {
88
+ return snapshot.localChecks.find((c) => c.name === name)!;
89
+ }
90
+
91
+ describe("discord readiness", () => {
92
+ test("asks the gateway about Discord's own socket", async () => {
93
+ await runDiscordProbe();
94
+
95
+ expect(askedChannels).toEqual(["discord"]);
96
+ });
97
+
98
+ test("a fresh install has neither a token nor an allow-list", async () => {
99
+ const [snapshot] = await runDiscordProbe();
100
+
101
+ expect(findCheck(snapshot, "bot_token").passed).toBe(false);
102
+ expect(findCheck(snapshot, "guild_admission").passed).toBe(false);
103
+ // Nothing configured at all, which is a different report from a channel
104
+ // half set up: no step has been taken rather than one left undone.
105
+ expect(snapshot.setupStatus).toBe("not_configured");
106
+ });
107
+
108
+ test("an allow-list without a token is half set up", async () => {
109
+ admittedChannelCount = 3;
110
+
111
+ const [snapshot] = await runDiscordProbe();
112
+
113
+ expect(findCheck(snapshot, "bot_token").passed).toBe(false);
114
+ expect(snapshot.setupStatus).toBe("incomplete");
115
+ });
116
+
117
+ test("a token, a live connection and an allow-list read as ready", async () => {
118
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
119
+ admittedChannelCount = 2;
120
+ socketHealth = {
121
+ channel: "discord",
122
+ status: "connected",
123
+ lastLivenessAt: Date.parse("2026-08-21T17:00:00.000Z"),
124
+ };
125
+
126
+ const [snapshot] = await runDiscordProbe();
127
+
128
+ expect(findCheck(snapshot, "bot_token").passed).toBe(true);
129
+ const delivery = findCheck(snapshot, "inbound_delivery");
130
+ expect(delivery.passed).toBe(true);
131
+ expect(delivery.message).toContain("Discord");
132
+ expect(delivery.message).toContain("2026-08-21T17:00:00.000Z");
133
+ expect(findCheck(snapshot, "guild_admission").message).toContain(
134
+ "2 channels",
135
+ );
136
+ expect(snapshot.ready).toBe(true);
137
+ });
138
+
139
+ test("an empty allow-list is unfinished setup, not a live channel", async () => {
140
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
141
+ admittedChannelCount = 0;
142
+
143
+ const [snapshot] = await runDiscordProbe();
144
+
145
+ // A connected socket that admits nothing still drops every guild message,
146
+ // so claiming ready here would promise delivery the gate is refusing.
147
+ const admission = findCheck(snapshot, "guild_admission");
148
+ expect(admission.passed).toBe(false);
149
+ expect(snapshot.setupStatus).toBe("incomplete");
150
+ expect(snapshot.ready).toBe(false);
151
+
152
+ // And it is a setup step rather than an outage: direct messages carry no
153
+ // guild and never meet this gate, so the connection itself is fine.
154
+ expect(snapshot.health).not.toBe("failing");
155
+ expect(findCheck(snapshot, "inbound_delivery").passed).toBe(true);
156
+ });
157
+
158
+ test("one admitted channel reads in the singular", async () => {
159
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
160
+ admittedChannelCount = 1;
161
+
162
+ const [snapshot] = await runDiscordProbe();
163
+
164
+ expect(findCheck(snapshot, "guild_admission").message).toContain(
165
+ "1 channel",
166
+ );
167
+ expect(findCheck(snapshot, "guild_admission").message).not.toContain(
168
+ "1 channels",
169
+ );
170
+ });
171
+
172
+ test("an unreachable gateway does not claim the allow-list is empty", async () => {
173
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
174
+ admittedChannelCount = new Error("gateway down");
175
+
176
+ const [snapshot] = await runDiscordProbe();
177
+
178
+ const admission = findCheck(snapshot, "guild_admission");
179
+ expect(admission.passed).toBe(true);
180
+ expect(admission.indeterminate).toBe(true);
181
+ });
182
+
183
+ test("a dead socket reads as configured and failing, not unconfigured", async () => {
184
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
185
+ // Setup has to be finished for this to isolate the socket: an empty
186
+ // allow-list would leave it incomplete for a reason that is not the socket.
187
+ admittedChannelCount = 1;
188
+ socketHealth = { channel: "discord", status: "disconnected" };
189
+
190
+ const [snapshot] = await runDiscordProbe();
191
+
192
+ // The distinction the operational kind exists for: setup finished, and the
193
+ // channel is down. Reporting it as unconfigured would send someone back
194
+ // through a setup flow that has nothing left to do.
195
+ expect(snapshot.setupStatus).toBe("ready");
196
+ expect(snapshot.health).toBe("failing");
197
+ expect(snapshot.ready).toBe(false);
198
+ expect(findCheck(snapshot, "inbound_delivery").message).toMatch(
199
+ /not reaching this assistant/i,
200
+ );
201
+ });
202
+
203
+ test("an unreachable gateway is indeterminate, never a fault", async () => {
204
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
205
+ socketHealth = new Error("gateway down");
206
+
207
+ const [snapshot] = await runDiscordProbe();
208
+
209
+ const delivery = findCheck(snapshot, "inbound_delivery");
210
+ expect(delivery.passed).toBe(true);
211
+ expect(delivery.indeterminate).toBe(true);
212
+ // Not evidence of a fault, so it may not show the channel as broken, and
213
+ // not evidence of delivery either, so it may not make it ready.
214
+ expect(snapshot.health).not.toBe("failing");
215
+ });
216
+
217
+ test("Discord runs no remote checks, so nothing calls out to it", async () => {
218
+ mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
219
+
220
+ const { createReadinessService } =
221
+ await import("../runtime/channel-readiness-service.js");
222
+ const [snapshot] = await createReadinessService().getReadiness(
223
+ "discord",
224
+ true,
225
+ );
226
+
227
+ // One bot token serves both the gateway connection and the sends, so a
228
+ // live socket already proves the credential a send would use.
229
+ expect(snapshot.remoteChecks ?? []).toEqual([]);
230
+ });
231
+ });
@@ -566,3 +566,129 @@ describe("ChannelReadinessService", () => {
566
566
  expect(probe.remoteCallCount).toBe(1);
567
567
  });
568
568
  });
569
+
570
+ describe("setup progress and operational health are separate axes", () => {
571
+ const configured = (name: string): ReadinessCheckResult => ({
572
+ name,
573
+ passed: true,
574
+ message: "configured",
575
+ });
576
+
577
+ test("a configured channel whose delivery fails is set up, not half set up", () => {
578
+ // The defect this separates: telling the owner of a fully configured
579
+ // channel to finish setup sends them to fix the one thing that is not
580
+ // broken. Setup is done; delivery is down.
581
+ const service = new ChannelReadinessService();
582
+ service.registerProbe(
583
+ makeProbe(
584
+ "phone",
585
+ [configured("creds")],
586
+ [
587
+ {
588
+ name: "inbound_delivery",
589
+ passed: false,
590
+ message: "nothing is arriving",
591
+ kind: "operational",
592
+ },
593
+ ],
594
+ ),
595
+ );
596
+
597
+ return service.getReadiness("phone", true).then(([snapshot]) => {
598
+ expect(snapshot.setupStatus).toBe("ready");
599
+ expect(snapshot.health).toBe("failing");
600
+ expect(snapshot.ready).toBe(false);
601
+ });
602
+ });
603
+
604
+ test("a delivery check that establishes nothing reads unknown, not failing", async () => {
605
+ const service = new ChannelReadinessService();
606
+ service.registerProbe(
607
+ makeProbe(
608
+ "phone",
609
+ [configured("creds")],
610
+ [
611
+ {
612
+ name: "inbound_delivery",
613
+ passed: true,
614
+ indeterminate: true,
615
+ message: "could not reach the provider",
616
+ kind: "operational",
617
+ },
618
+ ],
619
+ ),
620
+ );
621
+
622
+ const [snapshot] = await service.getReadiness("phone", true);
623
+ expect(snapshot.setupStatus).toBe("ready");
624
+ expect(snapshot.health).toBe("unknown");
625
+ // Unknown is not proof, so it cannot make the channel ready, and it is
626
+ // not a fault, so it must not be reported as one.
627
+ expect(snapshot.ready).toBe(false);
628
+ expect(snapshot.reasons).toEqual([]);
629
+ });
630
+
631
+ test("a channel that measures nothing operational reports no health at all", async () => {
632
+ // Absent, not "unknown": nothing was asked, so nothing is claimed, and
633
+ // readiness rests on configuration alone. This is the shape every channel
634
+ // without a delivery check still has today.
635
+ const service = new ChannelReadinessService();
636
+ service.registerProbe(makeProbe("phone", [configured("creds")]));
637
+
638
+ const [snapshot] = await service.getReadiness("phone", true);
639
+ expect(snapshot.health).toBeUndefined();
640
+ expect(snapshot.setupStatus).toBe("ready");
641
+ expect(snapshot.ready).toBe(true);
642
+ });
643
+
644
+ test("incomplete configuration still reads incomplete", async () => {
645
+ const service = new ChannelReadinessService();
646
+ service.registerProbe(
647
+ makeProbe("phone", [
648
+ configured("creds"),
649
+ { name: "ingress", passed: false, message: "no public URL" },
650
+ ]),
651
+ );
652
+
653
+ const [snapshot] = await service.getReadiness("phone", true);
654
+ expect(snapshot.setupStatus).toBe("incomplete");
655
+ expect(snapshot.ready).toBe(false);
656
+ });
657
+
658
+ test("an untouched channel is not_configured, not incomplete", async () => {
659
+ const service = new ChannelReadinessService();
660
+ service.registerProbe(
661
+ makeProbe("phone", [
662
+ { name: "creds", passed: false, message: "not configured" },
663
+ ]),
664
+ );
665
+
666
+ const [snapshot] = await service.getReadiness("phone", true);
667
+ expect(snapshot.setupStatus).toBe("not_configured");
668
+ expect(snapshot.ready).toBe(false);
669
+ });
670
+
671
+ test("ready still means every check verified, exactly as before", async () => {
672
+ // The safety property for this change: splitting the derivation must not
673
+ // move `ready` for any input, because the CLI and the web both gate on it.
674
+ const service = new ChannelReadinessService();
675
+ service.registerProbe(
676
+ makeProbe(
677
+ "phone",
678
+ [configured("creds")],
679
+ [
680
+ {
681
+ name: "inbound_delivery",
682
+ passed: true,
683
+ message: "arriving",
684
+ kind: "operational",
685
+ },
686
+ ],
687
+ ),
688
+ );
689
+
690
+ const [snapshot] = await service.getReadiness("phone", true);
691
+ expect(snapshot.ready).toBe(true);
692
+ expect(snapshot.health).toBe("ok");
693
+ });
694
+ });