@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.
- package/docs/architecture/memory.md +7 -0
- package/openapi.yaml +30 -5
- package/package.json +1 -1
- package/src/__tests__/app-conversation-ids-backfill.test.ts +33 -2
- package/src/__tests__/app-conversation-ids.test.ts +204 -0
- package/src/__tests__/channel-readiness-service.test.ts +107 -1
- package/src/__tests__/conversation-group-tools.test.ts +279 -0
- package/src/__tests__/conversation-lineage.test.ts +235 -0
- package/src/__tests__/platform-callback-registration.test.ts +142 -12
- package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
- package/src/__tests__/resolve-app-id.test.ts +32 -0
- package/src/__tests__/starter-task-flow.test.ts +24 -4
- package/src/__tests__/telegram-config.test.ts +96 -2
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
- package/src/__tests__/workspace-policy.test.ts +83 -2
- package/src/api/responses/subagent-detail.ts +13 -0
- package/src/apps/app-store.ts +95 -4
- package/src/calls/__tests__/voice-session-bridge.test.ts +71 -3
- package/src/calls/voice-session-bridge.ts +22 -7
- package/src/cli/commands/webhooks.help.ts +9 -4
- package/src/config/__tests__/webhook-routing.test.ts +229 -0
- package/src/config/bundled-skills/conversation-groups/SKILL.md +29 -0
- package/src/config/bundled-skills/conversation-groups/TOOLS.json +58 -0
- package/src/config/bundled-skills/conversation-groups/tools/group-create.ts +12 -0
- package/src/config/bundled-skills/conversation-groups/tools/group-list.ts +12 -0
- package/src/config/bundled-skills/conversation-groups/tools/move-to-group.ts +12 -0
- package/src/config/bundled-skills/schedule/SKILL.md +2 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/webhook-routing.ts +55 -14
- package/src/daemon/conversation-lineage.ts +76 -0
- package/src/daemon/conversation-surfaces.ts +4 -10
- package/src/daemon/handlers/config-telegram.ts +12 -7
- package/src/daemon/tool-side-effects.ts +27 -11
- package/src/inbound/platform-callback-registration.ts +44 -10
- package/src/inbound/public-ingress-urls.ts +40 -9
- package/src/live-voice/__tests__/live-voice-continuation-fork-hydration.test.ts +286 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +889 -167
- package/src/live-voice/live-voice-session.ts +733 -201
- package/src/messaging/providers/slack/binding-metadata.test.ts +37 -8
- package/src/messaging/providers/slack/binding-metadata.ts +21 -21
- package/src/messaging/providers/slack/deep-link.test.ts +91 -0
- package/src/messaging/providers/slack/deep-link.ts +27 -8
- package/src/permissions/checker.test.ts +213 -6
- package/src/permissions/checker.ts +85 -14
- package/src/permissions/workspace-policy.ts +51 -18
- package/src/persistence/conversation-parent.ts +27 -0
- package/src/persistence/migrations/354-backfill-app-conversation-lineage.ts +41 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +22 -0
- package/src/plugins/defaults/memory/src/memory-item-routes.ts +22 -3
- package/src/prompts/sections.ts +4 -16
- package/src/runtime/channel-readiness-service.ts +8 -10
- package/src/runtime/routes/__tests__/subagents-routes.test.ts +250 -0
- package/src/runtime/routes/__tests__/webhook-routes.test.ts +201 -0
- package/src/runtime/routes/subagents-routes.ts +35 -8
- package/src/runtime/routes/webhook-routes.ts +77 -30
- package/src/telegram/__tests__/webhook-health.test.ts +76 -13
- package/src/telegram/webhook-health.ts +1 -1
- package/src/tools/__tests__/conversation-group-tool-input-schemas.test.ts +61 -0
- package/src/tools/apps/resolve-app-id.ts +15 -3
- package/src/tools/conversation-groups/group_create.ts +63 -0
- package/src/tools/conversation-groups/group_list.ts +20 -0
- package/src/tools/conversation-groups/group_shared.ts +52 -0
- package/src/tools/conversation-groups/move_to_group.ts +106 -0
- package/src/util/platform.ts +15 -0
- 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:
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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 (
|
|
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
|
-
|
|
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" });
|