@vellumai/assistant 0.11.0-staging.1 → 0.11.0-staging.3

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 (67) hide show
  1. package/docs/architecture/memory.md +7 -0
  2. package/openapi.yaml +30 -5
  3. package/package.json +1 -1
  4. package/src/__tests__/app-conversation-ids-backfill.test.ts +33 -2
  5. package/src/__tests__/app-conversation-ids.test.ts +204 -0
  6. package/src/__tests__/channel-readiness-service.test.ts +107 -1
  7. package/src/__tests__/conversation-group-tools.test.ts +279 -0
  8. package/src/__tests__/conversation-lineage.test.ts +235 -0
  9. package/src/__tests__/platform-callback-registration.test.ts +142 -12
  10. package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
  11. package/src/__tests__/resolve-app-id.test.ts +32 -0
  12. package/src/__tests__/starter-task-flow.test.ts +24 -4
  13. package/src/__tests__/telegram-config.test.ts +96 -2
  14. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  15. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  16. package/src/__tests__/workspace-policy.test.ts +83 -2
  17. package/src/api/responses/subagent-detail.ts +13 -0
  18. package/src/apps/app-store.ts +95 -4
  19. package/src/calls/__tests__/voice-session-bridge.test.ts +71 -3
  20. package/src/calls/voice-session-bridge.ts +22 -7
  21. package/src/cli/commands/webhooks.help.ts +9 -4
  22. package/src/config/__tests__/webhook-routing.test.ts +229 -0
  23. package/src/config/bundled-skills/conversation-groups/SKILL.md +29 -0
  24. package/src/config/bundled-skills/conversation-groups/TOOLS.json +58 -0
  25. package/src/config/bundled-skills/conversation-groups/tools/group-create.ts +12 -0
  26. package/src/config/bundled-skills/conversation-groups/tools/group-list.ts +12 -0
  27. package/src/config/bundled-skills/conversation-groups/tools/move-to-group.ts +12 -0
  28. package/src/config/bundled-skills/schedule/SKILL.md +2 -0
  29. package/src/config/feature-flag-registry.json +0 -8
  30. package/src/config/webhook-routing.ts +55 -14
  31. package/src/daemon/conversation-lineage.ts +76 -0
  32. package/src/daemon/conversation-surfaces.ts +4 -10
  33. package/src/daemon/handlers/config-telegram.ts +12 -7
  34. package/src/daemon/tool-side-effects.ts +27 -11
  35. package/src/inbound/platform-callback-registration.ts +44 -10
  36. package/src/inbound/public-ingress-urls.ts +40 -9
  37. package/src/live-voice/__tests__/live-voice-continuation-fork-hydration.test.ts +286 -0
  38. package/src/live-voice/__tests__/live-voice-vad.test.ts +889 -167
  39. package/src/live-voice/live-voice-session.ts +733 -201
  40. package/src/messaging/providers/slack/binding-metadata.test.ts +37 -8
  41. package/src/messaging/providers/slack/binding-metadata.ts +21 -21
  42. package/src/messaging/providers/slack/deep-link.test.ts +91 -0
  43. package/src/messaging/providers/slack/deep-link.ts +27 -8
  44. package/src/permissions/checker.test.ts +213 -6
  45. package/src/permissions/checker.ts +85 -14
  46. package/src/permissions/workspace-policy.ts +51 -18
  47. package/src/persistence/conversation-parent.ts +27 -0
  48. package/src/persistence/migrations/354-backfill-app-conversation-lineage.ts +41 -0
  49. package/src/persistence/steps.ts +2 -0
  50. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +22 -0
  51. package/src/plugins/defaults/memory/src/memory-item-routes.ts +22 -3
  52. package/src/prompts/sections.ts +4 -16
  53. package/src/runtime/channel-readiness-service.ts +8 -10
  54. package/src/runtime/routes/__tests__/subagents-routes.test.ts +250 -0
  55. package/src/runtime/routes/__tests__/webhook-routes.test.ts +201 -0
  56. package/src/runtime/routes/subagents-routes.ts +35 -8
  57. package/src/runtime/routes/webhook-routes.ts +77 -30
  58. package/src/telegram/__tests__/webhook-health.test.ts +76 -13
  59. package/src/telegram/webhook-health.ts +1 -1
  60. package/src/tools/__tests__/conversation-group-tool-input-schemas.test.ts +61 -0
  61. package/src/tools/apps/resolve-app-id.ts +15 -3
  62. package/src/tools/conversation-groups/group_create.ts +63 -0
  63. package/src/tools/conversation-groups/group_list.ts +20 -0
  64. package/src/tools/conversation-groups/group_shared.ts +52 -0
  65. package/src/tools/conversation-groups/move_to_group.ts +106 -0
  66. package/src/util/platform.ts +15 -0
  67. package/tsconfig.plugin-api.json +1 -1
@@ -123,6 +123,13 @@ build can never drift. The same predicate gates procedural-memory-as-skills,
123
123
  so both v3-tier features honor the Memory opt-out identically. `GET /v1/memory-graph-node` serves node detail,
124
124
  including `buffer:` ids for pending entries.
125
125
 
126
+ `GET /v1/memory/stats` also reports `tier` (`memoryTier()`: `off` / `v1` /
127
+ `v2` / `v3`), which is what lets the web Memory tab explain an unavailable
128
+ graph instead of stating a bare "not available": `off` is the user's own
129
+ Memory opt-out and points at Settings, while `v1`/`v2` point at the v3
130
+ migration. `graph_supported` is exactly `tier === "v3"`, so the capability
131
+ bit and its explanation are derived from one gate.
132
+
126
133
  ## Capture beyond `remember`
127
134
 
128
135
  - **Retrospective** (`memory-retrospective-*.ts`): periodic per-conversation
package/openapi.yaml CHANGED
@@ -17747,7 +17747,9 @@ paths:
17747
17747
  "Return a cheap count of concept pages from the cached memory page index, for glanceable surfaces like the
17748
17748
  identity Memory card. Counts concept pages only and never builds the memory-concept graph. Also reports
17749
17749
  graph_supported: whether the memory-concept graph is available for this assistant (memory enabled and v3 live),
17750
- so callers can gate the graph entry point without building the graph."
17750
+ so callers can gate the graph entry point without building the graph, plus tier: the coarse memory tier
17751
+ explaining why the graph is unavailable (off = the user's Memory opt-out, v1/v2 = a legacy engine that has not
17752
+ migrated to v3)."
17751
17753
  tags:
17752
17754
  - memory
17753
17755
  responses:
@@ -17764,9 +17766,18 @@ paths:
17764
17766
  graph_supported:
17765
17767
  type: boolean
17766
17768
  description: Whether the memory-concept graph is available (memory enabled and v3 live)
17769
+ tier:
17770
+ type: string
17771
+ enum:
17772
+ - off
17773
+ - v1
17774
+ - v2
17775
+ - v3
17776
+ description: Coarse memory tier for this assistant; graph_supported is exactly tier === 'v3'
17767
17777
  required:
17768
17778
  - concepts
17769
17779
  - graph_supported
17780
+ - tier
17770
17781
  additionalProperties: false
17771
17782
  /v1/memory/v2/backfill:
17772
17783
  post:
@@ -29334,7 +29345,9 @@ paths:
29334
29345
  required: false
29335
29346
  schema:
29336
29347
  type: string
29337
- description: Parent conversation ID (required)
29348
+ description:
29349
+ The subagent's own conversation ID. Fallback only — when the daemon knows the subagent (live or
29350
+ rehydrated), it resolves the conversation itself and this parameter is ignored.
29338
29351
  responses:
29339
29352
  "200":
29340
29353
  description: Successful response
@@ -29397,6 +29410,10 @@ paths:
29397
29410
  - type
29398
29411
  - content
29399
29412
  additionalProperties: false
29413
+ label:
29414
+ type: string
29415
+ parentToolUseId:
29416
+ type: string
29400
29417
  required:
29401
29418
  - subagentId
29402
29419
  - events
@@ -29489,8 +29506,9 @@ paths:
29489
29506
  operationId: subagents_reconcile_get
29490
29507
  summary: Reconcile subagent live status
29491
29508
  description:
29492
- Returns the live in-memory status of all subagents known to the daemon for a given parent conversation.
29493
- Subagents not in the response are orphaned.
29509
+ Returns the live in-memory status of all subagents known to the daemon for a given parent conversation,
29510
+ with the identity fields a client needs to rebuild a store entry it never saw spawn (label, the subagent's own
29511
+ conversation id, and the spawning tool-use id). Subagents not in the response are orphaned.
29494
29512
  tags:
29495
29513
  - subagents
29496
29514
  parameters:
@@ -29517,6 +29535,12 @@ paths:
29517
29535
  properties:
29518
29536
  status:
29519
29537
  type: string
29538
+ label:
29539
+ type: string
29540
+ conversationId:
29541
+ type: string
29542
+ parentToolUseId:
29543
+ type: string
29520
29544
  required:
29521
29545
  - status
29522
29546
  additionalProperties: false
@@ -31536,7 +31560,8 @@ paths:
31536
31560
  summary: Register a webhook callback URL
31537
31561
  description:
31538
31562
  Resolves a stable callback URL for a webhook type. On platform-managed assistants, registers the route with
31539
- the platform gateway. On self-hosted assistants, uses the configured ingress.publicBaseUrl.
31563
+ the platform gateway. Otherwise uses the configured ingress.publicBaseUrl, falling back to the platform gateway
31564
+ when no ingress is configured and the assistant is connected to the platform.
31540
31565
  tags:
31541
31566
  - webhooks
31542
31567
  requestBody:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.0-staging.1",
3
+ "version": "0.11.0-staging.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import {
11
11
  } from "../apps/app-store.js";
12
12
  import { getDb } from "../persistence/db-connection.js";
13
13
  import { initializeDb } from "../persistence/db-init.js";
14
+ import { migrateBackfillAppConversationLineage } from "../persistence/migrations/354-backfill-app-conversation-lineage.js";
14
15
  import { rawRun } from "../persistence/raw-query.js";
15
16
 
16
17
  // Initialize db once for all tests
@@ -57,15 +58,18 @@ function insertMessage(
57
58
  }
58
59
 
59
60
  /** Insert a conversation row so FK constraints are satisfied. */
60
- function insertConversation(id: string): void {
61
+ function insertConversation(id: string, parentConversationId?: string): void {
61
62
  const now = Date.now();
62
63
  rawRun(
63
64
  "test:insertConversation",
64
- `INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`,
65
+ `INSERT OR IGNORE INTO conversations (
66
+ id, title, created_at, updated_at, parent_conversation_id
67
+ ) VALUES (?, ?, ?, ?, ?)`,
65
68
  id,
66
69
  "test",
67
70
  now,
68
71
  now,
72
+ parentConversationId ?? null,
69
73
  );
70
74
  }
71
75
 
@@ -272,3 +276,30 @@ describe("backfillAppConversationIds", () => {
272
276
  expect(loaded?.conversationIds).toEqual([existingConvId, backfillConvId]);
273
277
  });
274
278
  });
279
+
280
+ describe("migrateBackfillAppConversationLineage", () => {
281
+ test("adds inherited ancestors to existing direct app associations", () => {
282
+ const app = createApp(makeAppParams("Lineage App"));
283
+ const rootConversationId = "conv-lineage-root";
284
+ const parentConversationId = "conv-lineage-parent";
285
+ const childConversationId = "conv-lineage-child";
286
+ insertConversation(rootConversationId);
287
+ insertConversation(parentConversationId, rootConversationId);
288
+ insertConversation(childConversationId, parentConversationId);
289
+ addAppConversationId(app.id, childConversationId);
290
+
291
+ migrateBackfillAppConversationLineage();
292
+ migrateBackfillAppConversationLineage();
293
+
294
+ const loaded = getApp(app.id);
295
+ expect(loaded?.conversationIds).toEqual([
296
+ childConversationId,
297
+ parentConversationId,
298
+ rootConversationId,
299
+ ]);
300
+ expect(loaded?.inheritedConversationIds).toEqual([
301
+ parentConversationId,
302
+ rootConversationId,
303
+ ]);
304
+ });
305
+ });
@@ -7,8 +7,16 @@ import {
7
7
  addAppConversationId,
8
8
  createApp,
9
9
  getApp,
10
+ isDirectAppConversation,
11
+ linkAppToConversationLineage,
10
12
  listAppsByConversation,
11
13
  } from "../apps/app-store.js";
14
+ import {
15
+ deleteConversation,
16
+ removeSubagentConversation,
17
+ setConversation,
18
+ setSubagentConversation,
19
+ } from "../daemon/conversation-registry.js";
12
20
 
13
21
  // ---------------------------------------------------------------------------
14
22
  // Helpers
@@ -16,6 +24,41 @@ import {
16
24
 
17
25
  let testDataDir: string;
18
26
 
27
+ /**
28
+ * The registry stores live `Conversation` instances; lineage resolution only
29
+ * reads `parentConversationId`, so a minimal stand-in is enough.
30
+ */
31
+ type RegistryConversation = Parameters<typeof setConversation>[1];
32
+
33
+ const registeredTopLevel: string[] = [];
34
+ const registeredSubagents: [string, RegistryConversation][] = [];
35
+
36
+ function stubConversation(parentConversationId?: string): RegistryConversation {
37
+ return { parentConversationId } as unknown as RegistryConversation;
38
+ }
39
+
40
+ /** Register a resident top-level conversation, optionally forked from a parent. */
41
+ function registerConversation(id: string, parentId?: string): void {
42
+ setConversation(id, stubConversation(parentId));
43
+ registeredTopLevel.push(id);
44
+ }
45
+
46
+ /** Register a live subagent conversation forked from `parentId`. */
47
+ function registerSubagent(id: string, parentId: string): void {
48
+ const conversation = stubConversation(parentId);
49
+ setSubagentConversation(id, conversation);
50
+ registeredSubagents.push([id, conversation]);
51
+ }
52
+
53
+ function clearRegisteredConversations(): void {
54
+ for (const id of registeredTopLevel.splice(0)) {
55
+ deleteConversation(id);
56
+ }
57
+ for (const [id, conversation] of registeredSubagents.splice(0)) {
58
+ removeSubagentConversation(id, conversation);
59
+ }
60
+ }
61
+
19
62
  function freshTempDir(): string {
20
63
  return join(
21
64
  tmpdir(),
@@ -41,6 +84,7 @@ beforeEach(() => {
41
84
  });
42
85
 
43
86
  afterEach(() => {
87
+ clearRegisteredConversations();
44
88
  if (existsSync(testDataDir)) {
45
89
  rmSync(testDataDir, { recursive: true, force: true });
46
90
  }
@@ -149,3 +193,163 @@ describe("listAppsByConversation", () => {
149
193
  expect(result).toEqual([]);
150
194
  });
151
195
  });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // linkAppToConversationLineage
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe("linkAppToConversationLineage", () => {
202
+ test("an app created in a forked conversation carries the fork and its parent", () => {
203
+ registerConversation("conv-visible");
204
+ registerSubagent("conv-fork", "conv-visible");
205
+
206
+ const app = createApp(makeAppParams("Continuation App"));
207
+ linkAppToConversationLineage(app.id, "conv-fork");
208
+
209
+ expect(getApp(app.id)?.conversationIds).toEqual([
210
+ "conv-fork",
211
+ "conv-visible",
212
+ ]);
213
+ });
214
+
215
+ test("the user-visible ancestor lists an app created by a background subagent", () => {
216
+ registerConversation("conv-visible");
217
+ registerSubagent("conv-fork", "conv-visible");
218
+
219
+ const app = createApp(makeAppParams("Continuation App"));
220
+ linkAppToConversationLineage(app.id, "conv-fork");
221
+
222
+ const visible = listAppsByConversation("conv-visible");
223
+ expect(visible).toHaveLength(1);
224
+ expect(visible[0].id).toBe(app.id);
225
+ });
226
+
227
+ test("walks nested subagents up to the user-visible thread", () => {
228
+ registerConversation("conv-visible");
229
+ registerSubagent("conv-outer", "conv-visible");
230
+ registerSubagent("conv-inner", "conv-outer");
231
+
232
+ const app = createApp(makeAppParams("Nested App"));
233
+ linkAppToConversationLineage(app.id, "conv-inner");
234
+
235
+ expect(getApp(app.id)?.conversationIds).toEqual([
236
+ "conv-inner",
237
+ "conv-outer",
238
+ "conv-visible",
239
+ ]);
240
+ });
241
+
242
+ test("re-linking the same lineage is idempotent", () => {
243
+ registerConversation("conv-visible");
244
+ registerSubagent("conv-fork", "conv-visible");
245
+
246
+ const app = createApp(makeAppParams("Repeat App"));
247
+ linkAppToConversationLineage(app.id, "conv-fork");
248
+ linkAppToConversationLineage(app.id, "conv-fork");
249
+
250
+ expect(getApp(app.id)?.conversationIds).toEqual([
251
+ "conv-fork",
252
+ "conv-visible",
253
+ ]);
254
+ });
255
+
256
+ test("a lineage of one behaves exactly like addAppConversationId", () => {
257
+ registerConversation("conv-plain");
258
+
259
+ const linked = createApp(makeAppParams("Linked App"));
260
+ const direct = createApp(makeAppParams("Direct App"));
261
+ linkAppToConversationLineage(linked.id, "conv-plain");
262
+ addAppConversationId(direct.id, "conv-plain");
263
+
264
+ expect(getApp(linked.id)?.conversationIds).toEqual(["conv-plain"]);
265
+ expect(getApp(direct.id)?.conversationIds).toEqual(
266
+ getApp(linked.id)?.conversationIds,
267
+ );
268
+ expect(listAppsByConversation("conv-plain")).toHaveLength(2);
269
+ });
270
+
271
+ test("an unregistered conversation links only itself", () => {
272
+ const app = createApp(makeAppParams("Orphan App"));
273
+ linkAppToConversationLineage(app.id, "conv-gone");
274
+
275
+ expect(getApp(app.id)?.conversationIds).toEqual(["conv-gone"]);
276
+ });
277
+
278
+ test("a non-existent app is a no-op", () => {
279
+ registerConversation("conv-visible");
280
+ registerSubagent("conv-fork", "conv-visible");
281
+
282
+ expect(() =>
283
+ linkAppToConversationLineage("nonexistent-id", "conv-fork"),
284
+ ).not.toThrow();
285
+ });
286
+ });
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Direct vs inherited associations
290
+ // ---------------------------------------------------------------------------
291
+
292
+ describe("isDirectAppConversation", () => {
293
+ test("the seed conversation is direct and its ancestors are inherited", () => {
294
+ registerConversation("conv-visible");
295
+ registerSubagent("conv-fork", "conv-visible");
296
+
297
+ const app = createApp(makeAppParams("Continuation App"));
298
+ linkAppToConversationLineage(app.id, "conv-fork");
299
+
300
+ const loaded = getApp(app.id)!;
301
+ expect(loaded.inheritedConversationIds).toEqual(["conv-visible"]);
302
+ expect(isDirectAppConversation(loaded, "conv-fork")).toBe(true);
303
+ expect(isDirectAppConversation(loaded, "conv-visible")).toBe(false);
304
+ });
305
+
306
+ test("an inherited association still lists the app in the ancestor thread", () => {
307
+ registerConversation("conv-visible");
308
+ registerSubagent("conv-fork", "conv-visible");
309
+
310
+ const app = createApp(makeAppParams("Continuation App"));
311
+ linkAppToConversationLineage(app.id, "conv-fork");
312
+
313
+ expect(listAppsByConversation("conv-visible").map((a) => a.id)).toEqual([
314
+ app.id,
315
+ ]);
316
+ });
317
+
318
+ test("a record carrying no inherited list treats every association as direct", () => {
319
+ const app = createApp(makeAppParams("Plain App"));
320
+ addAppConversationId(app.id, "conv-plain");
321
+
322
+ const loaded = getApp(app.id)!;
323
+ expect(loaded.inheritedConversationIds).toBeUndefined();
324
+ expect(isDirectAppConversation(loaded, "conv-plain")).toBe(true);
325
+ });
326
+
327
+ test("a direct association supersedes an earlier inherited one", () => {
328
+ registerConversation("conv-visible");
329
+ registerSubagent("conv-fork", "conv-visible");
330
+
331
+ const app = createApp(makeAppParams("Adopted App"));
332
+ linkAppToConversationLineage(app.id, "conv-fork");
333
+ // The user later works on the same app in the thread they can see.
334
+ expect(addAppConversationId(app.id, "conv-visible")).toBe(false);
335
+
336
+ const loaded = getApp(app.id)!;
337
+ expect(loaded.conversationIds).toEqual(["conv-fork", "conv-visible"]);
338
+ expect(loaded.inheritedConversationIds).toEqual([]);
339
+ expect(isDirectAppConversation(loaded, "conv-visible")).toBe(true);
340
+ });
341
+
342
+ test("an inherited link never demotes an existing direct association", () => {
343
+ registerConversation("conv-visible");
344
+ registerSubagent("conv-fork", "conv-visible");
345
+
346
+ const app = createApp(makeAppParams("Foreground App"));
347
+ addAppConversationId(app.id, "conv-visible");
348
+ linkAppToConversationLineage(app.id, "conv-fork");
349
+
350
+ const loaded = getApp(app.id)!;
351
+ expect(loaded.conversationIds).toEqual(["conv-visible", "conv-fork"]);
352
+ expect(loaded.inheritedConversationIds).toBeUndefined();
353
+ expect(isDirectAppConversation(loaded, "conv-visible")).toBe(true);
354
+ });
355
+ });
@@ -6,6 +6,8 @@ import { setConfig } from "./helpers/set-config.js";
6
6
  let mockSecureKeys: Record<string, string>;
7
7
  let mockHasTwilioCredentials: boolean;
8
8
  let mockGetIsPlatform: boolean;
9
+ /** Platform credentials present: base URL + assistant ID + assistant API key. */
10
+ let mockPlatformConnected: boolean;
9
11
 
10
12
  mock.module("../calls/twilio-rest.js", () => ({
11
13
  getPhoneNumberSid: async () => null,
@@ -24,7 +26,22 @@ mock.module("../config/env-registry.js", () => ({
24
26
  getIsPlatform: () => mockGetIsPlatform,
25
27
  }));
26
28
 
27
- mock.module("../inbound/platform-callback-registration.js", () => ({}));
29
+ // Spread the real module: replacing it wholesale drops the exports peer test
30
+ // files import from it, which breaks their named-import validation whenever
31
+ // this mock wins evaluation in a combined run.
32
+ const actualRegistration =
33
+ await import("../inbound/platform-callback-registration.js");
34
+ mock.module("../inbound/platform-callback-registration.js", () => ({
35
+ ...actualRegistration,
36
+ resolvePlatformCallbackRegistrationContext: async () => ({
37
+ isPlatform: mockGetIsPlatform,
38
+ platformBaseUrl: "https://api.vellum.ai",
39
+ assistantId: mockPlatformConnected ? "assistant-123" : "",
40
+ hasAssistantApiKey: mockPlatformConnected,
41
+ authHeader: mockPlatformConnected ? "Api-Key secret" : null,
42
+ enabled: mockPlatformConnected,
43
+ }),
44
+ }));
28
45
 
29
46
  mock.module("../security/secure-keys.js", () => ({
30
47
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
@@ -85,6 +102,7 @@ describe("ChannelReadinessService", () => {
85
102
  mockSecureKeys = {};
86
103
  mockHasTwilioCredentials = false;
87
104
  mockGetIsPlatform = false;
105
+ mockPlatformConnected = false;
88
106
  });
89
107
 
90
108
  test("local checks run on every call (no caching of local results)", async () => {
@@ -278,6 +296,94 @@ describe("ChannelReadinessService", () => {
278
296
  });
279
297
  });
280
298
 
299
+ test("telegram readiness accepts a platform-connected assistant with no ingress", async () => {
300
+ // LUM-2882: `webhooks register telegram` resolves a platform callback URL
301
+ // in this configuration, so reporting missing ingress here would hide a
302
+ // registration that is really in place (or really broken).
303
+ mockPlatformConnected = true;
304
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
305
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
306
+
307
+ const readiness = createReadinessService();
308
+ const [snapshot] = await readiness.getReadiness("telegram");
309
+
310
+ expect(snapshot.ready).toBe(true);
311
+ expect(snapshot.localChecks).toContainEqual({
312
+ name: "ingress",
313
+ passed: true,
314
+ message: "Managed platform callback routing is configured",
315
+ });
316
+ });
317
+
318
+ test("phone readiness accepts a platform-connected assistant with no ingress", async () => {
319
+ mockPlatformConnected = true;
320
+ mockHasTwilioCredentials = true;
321
+ setConfig("twilio", { phoneNumber: "+15550123" });
322
+
323
+ const readiness = createReadinessService();
324
+ const [snapshot] = await readiness.getReadiness("phone");
325
+
326
+ expect(snapshot.ready).toBe(true);
327
+ expect(snapshot.localChecks).toContainEqual({
328
+ name: "ingress",
329
+ passed: true,
330
+ message: "Managed platform callback routing is configured",
331
+ });
332
+ });
333
+
334
+ test("configured ingress beats the platform-connected fallback", async () => {
335
+ // Any logged-in local assistant holds platform credentials for the LLM
336
+ // proxy. Reporting managed routing here would mislabel a webhook that
337
+ // `webhooks register` resolves to the self-hosted URL.
338
+ mockPlatformConnected = true;
339
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
340
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
341
+ setConfig("ingress", { publicBaseUrl: "https://tunnel.example.com" });
342
+
343
+ const readiness = createReadinessService();
344
+ const [snapshot] = await readiness.getReadiness("telegram");
345
+
346
+ expect(snapshot.ready).toBe(true);
347
+ expect(snapshot.localChecks).toContainEqual({
348
+ name: "ingress",
349
+ passed: true,
350
+ message: "Public ingress URL is configured",
351
+ });
352
+ });
353
+
354
+ test("explicit ingress opt-out blocks the platform-connected fallback", async () => {
355
+ // Switching ingress off is a decision not to accept inbound webhooks, not
356
+ // an invitation to route them through the platform instead.
357
+ mockPlatformConnected = true;
358
+ mockSecureKeys[credentialKey("telegram", "bot_token")] = "123:abc";
359
+ mockSecureKeys[credentialKey("telegram", "webhook_secret")] = "secret";
360
+ setConfig("ingress", { enabled: false });
361
+
362
+ const readiness = createReadinessService();
363
+ const [snapshot] = await readiness.getReadiness("telegram");
364
+
365
+ expect(snapshot.ready).toBe(false);
366
+ expect(snapshot.reasons).toContainEqual({
367
+ code: "ingress",
368
+ text: "No public ingress URL or managed callback route is configured",
369
+ });
370
+ });
371
+
372
+ test("email readiness ignores platform connectivity", async () => {
373
+ // Email passes `allowManagedCallbacks: false`, so the managed tiers are
374
+ // not offered to it and only a real ingress URL counts.
375
+ mockPlatformConnected = true;
376
+
377
+ const readiness = createReadinessService();
378
+ const [snapshot] = await readiness.getReadiness("email");
379
+
380
+ expect(snapshot.localChecks).toContainEqual({
381
+ name: "ingress",
382
+ passed: false,
383
+ message: "Public ingress URL is not configured or disabled",
384
+ });
385
+ });
386
+
281
387
  test("phone readiness accepts configured public ingress", async () => {
282
388
  mockHasTwilioCredentials = true;
283
389
  setConfig("twilio", { phoneNumber: "+15550123" });