@vellumai/assistant 0.11.2-dev.202608070121.59f762f → 0.11.2-dev.202608070306.b97cdd6

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/conversation-initial-prompt.test.ts +51 -22
  3. package/src/__tests__/conversation-origin-channel-filter.test.ts +95 -0
  4. package/src/__tests__/guardian-outbound-http.test.ts +8 -8
  5. package/src/__tests__/persona-resolver.test.ts +154 -0
  6. package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
  7. package/src/__tests__/tool-side-effects-documents.test.ts +0 -6
  8. package/src/__tests__/tool-side-effects-hook-failures.test.ts +129 -0
  9. package/src/__tests__/verification-outbound-delivery.test.ts +176 -0
  10. package/src/config/feature-flag-registry.json +8 -0
  11. package/src/contacts/__tests__/guardian-delivery-reader.test.ts +16 -0
  12. package/src/contacts/guardian-delivery-reader.ts +27 -0
  13. package/src/daemon/conversation-initial-prompt.ts +16 -38
  14. package/src/daemon/identity-helpers.ts +38 -10
  15. package/src/daemon/tool-side-effects.ts +0 -90
  16. package/src/heartbeat/heartbeat-service.ts +6 -5
  17. package/src/notifications/__tests__/notification-utils.test.ts +1 -1
  18. package/src/persistence/conversation-queries.ts +27 -1
  19. package/src/persistence/conversation-types.ts +11 -0
  20. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +1 -1
  21. package/src/plugins/defaults/memory/memory-retrospective-job.ts +6 -5
  22. package/src/plugins/defaults/memory/substrate/sweep-job.ts +7 -0
  23. package/src/plugins/defaults/memory/v2/router.ts +8 -0
  24. package/src/prompts/persona-resolver.ts +6 -2
  25. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -0
  26. package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
  27. package/src/runtime/routes/channel-verification-routes.ts +2 -46
  28. package/src/runtime/verification-outbound-actions.ts +187 -345
  29. package/src/runtime/verification-templates.ts +2 -2
  30. package/src/__tests__/tool-side-effects-slack-dm.test.ts +0 -357
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608070121.59f762f",
3
+ "version": "0.11.2-dev.202608070306.b97cdd6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * A conversation's system prompt is built once at construction and frozen for
5
5
  * every turn (the agent loop never re-resolves it), so the persona slot must
6
- * resolve correctly here. With no construction-time identity the default-build
7
- * path must warm the gateway guardian binding BEFORE building (else a cold cache
8
- * pins `users/default.md`); a channel-routed conversation that already carries
9
- * the requester's trust context must build with it so the requester's profile
10
- * resolves instead of the guardian/default one.
6
+ * resolve correctly here. With no construction-time identity OR a
7
+ * guardian-class trust context (background/scheduled turns, memory
8
+ * consolidation, managed desktop) the guardian binding must be warmed BEFORE
9
+ * building (else a cold cache pins `users/default.md`); a channel-routed
10
+ * conversation carrying a non-guardian requester trust context must build with
11
+ * it directly so the requester's profile resolves via the DB contact lookup.
11
12
  *
12
13
  * Dependency seams are injected so this exercises the sequencing without mocking
13
14
  * the widely-imported guardian-delivery / system-prompt modules (those global
@@ -15,10 +16,7 @@
15
16
  */
16
17
  import { describe, expect, test } from "bun:test";
17
18
 
18
- import {
19
- resolveInitialSystemPrompt,
20
- warmGuardianBindings,
21
- } from "../daemon/conversation-initial-prompt.js";
19
+ import { resolveInitialSystemPrompt } from "../daemon/conversation-initial-prompt.js";
22
20
  import type { ConversationCreateOptions } from "../daemon/handlers/shared.js";
23
21
  import type { TrustContext } from "../daemon/trust-context-types.js";
24
22
 
@@ -47,18 +45,22 @@ const REQUESTER_TRUST = {
47
45
  requesterExternalUserId: "user-123",
48
46
  } as unknown as TrustContext;
49
47
 
50
- describe("warmGuardianBindings", () => {
51
- test("warms both the vellum-channel key and the unfiltered fallback key", async () => {
52
- const keys: string[] = [];
53
- await warmGuardianBindings(async (input) => {
54
- keys.push(input?.channelTypes?.join(",") ?? "ALL");
55
- return null;
56
- });
57
- // Both keys the persona resolver reads — peekGuardianForChannel("vellum")
58
- // and its peekAnyGuardian() fallback — must be warmed.
59
- expect(keys.sort()).toEqual(["ALL", "vellum"]);
60
- });
61
- });
48
+ // The shape scheduler execute/reuse runs, memory consolidation, and other
49
+ // runBackgroundJob callers pass at construction: guardian class, no
50
+ // per-actor identity.
51
+ const GUARDIAN_BACKGROUND_TRUST = {
52
+ sourceChannel: "vellum",
53
+ trustClass: "guardian",
54
+ } as unknown as TrustContext;
55
+
56
+ // Managed desktop: guardian class WITH a requester principal id. The persona
57
+ // resolution may still need the guardian-delivery cache (contact lookup miss
58
+ // or null userFile falls through to the guardian file).
59
+ const GUARDIAN_REQUESTER_TRUST = {
60
+ sourceChannel: "vellum",
61
+ trustClass: "guardian",
62
+ requesterExternalUserId: "principal-123",
63
+ } as unknown as TrustContext;
62
64
 
63
65
  describe("resolveInitialSystemPrompt", () => {
64
66
  test("no construction-time identity: warms the guardian binding, then builds with no trust context", async () => {
@@ -72,7 +74,7 @@ describe("resolveInitialSystemPrompt", () => {
72
74
  expect(builtWith).toEqual([undefined]);
73
75
  });
74
76
 
75
- test("channel-routed: threads the requester trust context and skips the guardian warm", async () => {
77
+ test("non-guardian channel-routed: threads the requester trust context and skips the guardian warm", async () => {
76
78
  const { calls, builtWith, deps } = spyDeps();
77
79
  const result = await resolveInitialSystemPrompt(
78
80
  { trustContext: REQUESTER_TRUST } as ConversationCreateOptions,
@@ -86,6 +88,33 @@ describe("resolveInitialSystemPrompt", () => {
86
88
  expect(builtWith).toEqual([REQUESTER_TRUST]);
87
89
  });
88
90
 
91
+ test("guardian-class background shape: warms the guardian binding, then builds with the trust context", async () => {
92
+ const { calls, builtWith, deps } = spyDeps();
93
+ const result = await resolveInitialSystemPrompt(
94
+ { trustContext: GUARDIAN_BACKGROUND_TRUST } as ConversationCreateOptions,
95
+ deps,
96
+ );
97
+
98
+ expect(result).toBe("BUILD");
99
+ // Scheduled tasks and memory consolidation construct conversations in
100
+ // worker processes whose guardian-delivery cache starts cold; the warm
101
+ // MUST precede the build or the persona slot pins users/default.md.
102
+ expect(calls).toEqual(["warm", "build"]);
103
+ expect(builtWith).toEqual([GUARDIAN_BACKGROUND_TRUST]);
104
+ });
105
+
106
+ test("guardian-class with requester identity (managed desktop): still warms before building", async () => {
107
+ const { calls, builtWith, deps } = spyDeps();
108
+ const result = await resolveInitialSystemPrompt(
109
+ { trustContext: GUARDIAN_REQUESTER_TRUST } as ConversationCreateOptions,
110
+ deps,
111
+ );
112
+
113
+ expect(result).toBe("BUILD");
114
+ expect(calls).toEqual(["warm", "build"]);
115
+ expect(builtWith).toEqual([GUARDIAN_REQUESTER_TRUST]);
116
+ });
117
+
89
118
  test("an explicit override is used verbatim and skips the warm and build", async () => {
90
119
  const { calls, deps } = spyDeps();
91
120
  const result = await resolveInitialSystemPrompt(
@@ -0,0 +1,95 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { beforeEach, describe, expect, test } from "bun:test";
3
+
4
+ import { createConversation } from "../persistence/conversation-crud.js";
5
+ import { ensureGroupMigration } from "../persistence/conversation-group-migration.js";
6
+ import { listConversations } from "../persistence/conversation-queries.js";
7
+ import { getDb } from "../persistence/db-connection.js";
8
+ import { initializeDb } from "../persistence/db-init.js";
9
+
10
+ await initializeDb();
11
+ ensureGroupMigration();
12
+
13
+ function getRawDb(): Database {
14
+ return (getDb() as unknown as { $client: Database }).$client;
15
+ }
16
+
17
+ /**
18
+ * `originChannel` is what lets each sidebar channel card fetch its own rows,
19
+ * and what lets the Chats card ask for the ones no channel claimed.
20
+ *
21
+ * The asymmetry under test: `origin_channel` is deliberately NULL at insert so
22
+ * an inbound message can claim the conversation for its channel, and migration
23
+ * 288 settles the unclaimed ones to 'vellum' at daemon startup. So NULL means
24
+ * "not yet attributed", and between one boot and the next it is what most rows
25
+ * carry. Reading `vellum` strictly would leave every one of them in no section
26
+ * at all: not native, not any channel.
27
+ */
28
+ describe("listConversations originChannel filter", () => {
29
+ beforeEach(() => {
30
+ const raw = getRawDb();
31
+ raw.run("DELETE FROM conversations");
32
+ });
33
+
34
+ /** Set `origin_channel` directly; `createConversation` leaves it unset. */
35
+ function stamp(id: string, originChannel: string | null): void {
36
+ getRawDb().run("UPDATE conversations SET origin_channel = ? WHERE id = ?", [
37
+ originChannel,
38
+ id,
39
+ ]);
40
+ }
41
+
42
+ function listedIds(originChannel: string): string[] {
43
+ return listConversations({ originChannel } as Parameters<
44
+ typeof listConversations
45
+ >[0]).map((c) => c.id);
46
+ }
47
+
48
+ test("vellum matches rows that were never attributed", () => {
49
+ createConversation({ id: "conv-unattributed", source: "user" });
50
+ stamp("conv-unattributed", null);
51
+
52
+ expect(listedIds("vellum")).toEqual(["conv-unattributed"]);
53
+ });
54
+
55
+ test("vellum matches rows explicitly stamped vellum", () => {
56
+ createConversation({ id: "conv-native", source: "user" });
57
+ stamp("conv-native", "vellum");
58
+
59
+ expect(listedIds("vellum")).toEqual(["conv-native"]);
60
+ });
61
+
62
+ /* The whole point: an unattributed row and a stamped native row are the same
63
+ section. Asserting both are returned together, rather than each alone,
64
+ is what fails if the predicate goes back to a strict equality. */
65
+ test("vellum returns unattributed and stamped rows together", () => {
66
+ createConversation({ id: "conv-native", source: "user" });
67
+ stamp("conv-native", "vellum");
68
+ createConversation({ id: "conv-unattributed", source: "user" });
69
+ stamp("conv-unattributed", null);
70
+
71
+ expect(listedIds("vellum").sort()).toEqual([
72
+ "conv-native",
73
+ "conv-unattributed",
74
+ ]);
75
+ });
76
+
77
+ test("vellum does not claim a row belonging to a channel", () => {
78
+ createConversation({ id: "conv-slack", source: "user" });
79
+ stamp("conv-slack", "slack");
80
+
81
+ expect(listedIds("vellum")).toEqual([]);
82
+ });
83
+
84
+ /* Only `vellum` is tolerant. A row is claimed for a channel by that channel
85
+ alone, so there is no ambiguity for the others to absorb - and a tolerant
86
+ `slack` would pull every unattributed row into the Slack card. */
87
+ test("a channel matches exactly and never absorbs unattributed rows", () => {
88
+ createConversation({ id: "conv-slack", source: "user" });
89
+ stamp("conv-slack", "slack");
90
+ createConversation({ id: "conv-unattributed", source: "user" });
91
+ stamp("conv-unattributed", null);
92
+
93
+ expect(listedIds("slack")).toEqual(["conv-slack"]);
94
+ });
95
+ });
@@ -440,7 +440,7 @@ describe("HTTP route: handleCreateVerificationSession (guardian path)", () => {
440
440
  test("creates inbound challenge when destination is absent", async () => {
441
441
  const result = (await handleCreateVerificationSession({
442
442
  body: { channel: "phone" },
443
- })) as Record<string, unknown>;
443
+ })) as unknown as Record<string, unknown>;
444
444
  expect(result.success).toBe(true);
445
445
  expect(result.channel).toBe("phone");
446
446
  });
@@ -448,7 +448,7 @@ describe("HTTP route: handleCreateVerificationSession (guardian path)", () => {
448
448
  test("returns success for valid voice start", async () => {
449
449
  const result = (await handleCreateVerificationSession({
450
450
  body: { channel: "phone", destination: "+15559999999" },
451
- })) as Record<string, unknown>;
451
+ })) as unknown as Record<string, unknown>;
452
452
  expect(result.success).toBe(true);
453
453
  expect(result.verificationSessionId).toBeDefined();
454
454
  });
@@ -471,7 +471,7 @@ describe("HTTP route: handleResendVerificationSession (guardian path)", () => {
471
471
  // Start a session first
472
472
  const startResult = (await handleCreateVerificationSession({
473
473
  body: { channel: "phone", destination: "+15556667777" },
474
- })) as Record<string, unknown>;
474
+ })) as unknown as Record<string, unknown>;
475
475
  expect(startResult.success).toBe(true);
476
476
 
477
477
  // Expire the cooldown so resend is allowed
@@ -489,7 +489,7 @@ describe("HTTP route: handleResendVerificationSession (guardian path)", () => {
489
489
  channel: "phone",
490
490
  originConversationId: "conv-resend-http-origin",
491
491
  },
492
- })) as Record<string, unknown>;
492
+ })) as unknown as Record<string, unknown>;
493
493
  expect(resendResult.success).toBe(true);
494
494
  expect(resendResult.originConversationId).toBe("conv-resend-http-origin");
495
495
  });
@@ -505,7 +505,7 @@ describe("HTTP route: handleCancelVerificationSession (guardian path)", () => {
505
505
  test("returns success even when no active session exists", async () => {
506
506
  const result = (await handleCancelVerificationSession({
507
507
  body: { channel: "phone" },
508
- })) as Record<string, unknown>;
508
+ })) as unknown as Record<string, unknown>;
509
509
  expect(result.success).toBe(true);
510
510
  });
511
511
 
@@ -513,13 +513,13 @@ describe("HTTP route: handleCancelVerificationSession (guardian path)", () => {
513
513
  // Start a session
514
514
  const startResult = (await handleCreateVerificationSession({
515
515
  body: { channel: "phone", destination: "+15558887777" },
516
- })) as Record<string, unknown>;
516
+ })) as unknown as Record<string, unknown>;
517
517
  expect(startResult.success).toBe(true);
518
518
 
519
519
  // Cancel it
520
520
  const cancelResult = (await handleCancelVerificationSession({
521
521
  body: { channel: "phone" },
522
- })) as Record<string, unknown>;
522
+ })) as unknown as Record<string, unknown>;
523
523
  expect(cancelResult.success).toBe(true);
524
524
  });
525
525
  });
@@ -575,7 +575,7 @@ describe("origin conversation linkage", () => {
575
575
  destination: "+15557776666",
576
576
  originConversationId: "conv-origin-http-test",
577
577
  },
578
- })) as Record<string, unknown>;
578
+ })) as unknown as Record<string, unknown>;
579
579
  expect(result.success).toBe(true);
580
580
  expect(result.originConversationId).toBe("conv-origin-http-test");
581
581
  });
@@ -63,6 +63,8 @@ function seedVellumGuardian(userFile: string | null): void {
63
63
 
64
64
  mock.module("../util/platform.js", () => ({
65
65
  getWorkspaceDir: () => mockWorkspaceDir,
66
+ getWorkspacePromptPath: (filename: string) =>
67
+ join(mockWorkspaceDir, "prompts", filename),
66
68
  }));
67
69
 
68
70
  mock.module("../contacts/contact-store.js", () => ({
@@ -96,6 +98,7 @@ mock.module("../contacts/guardian-delivery-reader.js", () => ({
96
98
  // Import AFTER mocks so the module under test binds to the stubbed
97
99
  // implementations.
98
100
  import { getGuardianDelivery } from "../contacts/guardian-delivery-reader.js";
101
+ import { resolveUserName } from "../daemon/identity-helpers.js";
99
102
  import type { TrustContext } from "../daemon/trust-context-types.js";
100
103
  import {
101
104
  ensureGuardianPersonaFile,
@@ -408,3 +411,154 @@ describe("resolveUserSlug (guardian trust WITH requester identity)", () => {
408
411
  expect(resolveUserSlug(trustContext)).toBeNull();
409
412
  });
410
413
  });
414
+
415
+ // ── resolveUserSlug — guardian bound on a non-vellum channel ───────
416
+ //
417
+ // warmGuardianBindings warms both the channel-filtered and unfiltered cache
418
+ // keys, but a guardian may be bound only on a non-vellum channel (phone,
419
+ // Telegram). A guardian-class turn sourced elsewhere must still resolve that
420
+ // guardian instead of silently landing on users/default.md.
421
+
422
+ describe("resolveUserSlug (guardian bound on a non-vellum channel)", () => {
423
+ test("falls back to the any-channel guardian when the source channel has none", () => {
424
+ mockGuardianDeliveries = [
425
+ { channelType: "telegram", address: "guardian-tg", status: "active" },
426
+ ];
427
+ mockContactsByAddress["telegram:guardian-tg"] = { userFile: "alice.md" };
428
+
429
+ const trustContext = {
430
+ sourceChannel: "vellum",
431
+ trustClass: "guardian",
432
+ } as TrustContext;
433
+
434
+ expect(resolveUserSlug(trustContext)).toBe("alice");
435
+ });
436
+
437
+ test("any-channel guardian with no userFile resolves the guardian.md fallback", () => {
438
+ mockGuardianDeliveries = [
439
+ { channelType: "telegram", address: "guardian-tg", status: "active" },
440
+ ];
441
+ mockContactsByAddress["telegram:guardian-tg"] = { userFile: null };
442
+
443
+ const trustContext = {
444
+ sourceChannel: "vellum",
445
+ trustClass: "guardian",
446
+ } as TrustContext;
447
+
448
+ expect(resolveUserSlug(trustContext)).toBe("guardian");
449
+ });
450
+ });
451
+
452
+ // ── resolveUserSlug — cold cache then construction-time warm ───────
453
+ //
454
+ // The exact shape scheduled tasks and memory consolidation pass through
455
+ // resolveInitialSystemPrompt: guardian class, no requester identity, built in
456
+ // a worker process whose guardian-delivery cache starts cold.
457
+
458
+ describe("resolveUserSlug (cold cache, background shape)", () => {
459
+ test("misses on a cold cache, resolves the guardian after the construction-time warm", async () => {
460
+ pendingWarmDeliveries = [
461
+ { channelType: "vellum", address: "vellum:self", status: "active" },
462
+ ];
463
+ mockContactsByAddress["vellum:vellum:self"] = { userFile: "alice.md" };
464
+
465
+ const trustContext = {
466
+ sourceChannel: "vellum",
467
+ trustClass: "guardian",
468
+ } as TrustContext;
469
+
470
+ // Cold: the sync peek finds nothing and the slug falls back to default.
471
+ expect(resolveUserSlug(trustContext)).toBeNull();
472
+
473
+ // resolveInitialSystemPrompt warms guardian bindings for guardian-class
474
+ // contexts before building; afterwards the same resolution succeeds.
475
+ await getGuardianDelivery({ channelTypes: ["vellum"] });
476
+
477
+ expect(resolveUserSlug(trustContext)).toBe("alice");
478
+ });
479
+ });
480
+
481
+ // ── resolveUserName — guardian-preferred display name ──────────────
482
+ //
483
+ // The sweep job and v2 router address the user by name in their prompts. The
484
+ // name must come from the guardian's own persona file when one is resolvable,
485
+ // falling back to users/default.md.
486
+
487
+ describe("resolveUserName", () => {
488
+ test("prefers the Name label from the guardian's own persona file over default.md", () => {
489
+ seedVellumGuardian("alice.md");
490
+ const usersDir = join(mockWorkspaceDir, "users");
491
+ mkdirSync(usersDir, { recursive: true });
492
+ writeFileSync(join(usersDir, "alice.md"), "**Name:** Alice\n", "utf-8");
493
+ writeFileSync(
494
+ join(usersDir, "default.md"),
495
+ "**Name:** Default User\n",
496
+ "utf-8",
497
+ );
498
+
499
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Alice");
500
+ });
501
+
502
+ test("reads the onboarding-written Preferred name bullet", () => {
503
+ seedVellumGuardian("alice.md");
504
+ const usersDir = join(mockWorkspaceDir, "users");
505
+ mkdirSync(usersDir, { recursive: true });
506
+ writeFileSync(
507
+ join(usersDir, "alice.md"),
508
+ "# User Profile\n\n## Onboarding Context\n\n- **Preferred name:** Ali\n",
509
+ "utf-8",
510
+ );
511
+
512
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Ali");
513
+ });
514
+
515
+ test("reads the scaffold's filled Preferred name/reference line", () => {
516
+ seedVellumGuardian("alice.md");
517
+ const usersDir = join(mockWorkspaceDir, "users");
518
+ mkdirSync(usersDir, { recursive: true });
519
+ writeFileSync(
520
+ join(usersDir, "alice.md"),
521
+ "- Preferred name/reference: Alice\n- Pronouns: she/her\n",
522
+ "utf-8",
523
+ );
524
+
525
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Alice");
526
+ });
527
+
528
+ test("an unfilled scaffold does not swallow the next line and falls back to default.md", () => {
529
+ seedVellumGuardian("alice.md");
530
+ const usersDir = join(mockWorkspaceDir, "users");
531
+ mkdirSync(usersDir, { recursive: true });
532
+ // The bare scaffold: every label present, every value unfilled.
533
+ ensureGuardianPersonaFile("alice.md");
534
+ writeFileSync(join(usersDir, "default.md"), "**Name:** Bob\n", "utf-8");
535
+
536
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Bob");
537
+ });
538
+
539
+ test("a declined_by_user sentinel is not used as a name", () => {
540
+ seedVellumGuardian("alice.md");
541
+ const usersDir = join(mockWorkspaceDir, "users");
542
+ mkdirSync(usersDir, { recursive: true });
543
+ writeFileSync(
544
+ join(usersDir, "alice.md"),
545
+ "- Preferred name/reference: declined_by_user\n",
546
+ "utf-8",
547
+ );
548
+ writeFileSync(join(usersDir, "default.md"), "**Name:** Bob\n", "utf-8");
549
+
550
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Bob");
551
+ });
552
+
553
+ test("falls back to default.md when no guardian resolves", () => {
554
+ const usersDir = join(mockWorkspaceDir, "users");
555
+ mkdirSync(usersDir, { recursive: true });
556
+ writeFileSync(join(usersDir, "default.md"), "**Name:** Bob\n", "utf-8");
557
+
558
+ expect(resolveUserName(mockWorkspaceDir)).toBe("Bob");
559
+ });
560
+
561
+ test("returns null when neither file yields a name", () => {
562
+ expect(resolveUserName(mockWorkspaceDir)).toBeNull();
563
+ });
564
+ });
@@ -115,6 +115,12 @@ const BASELINE: Record<string, readonly string[]> = {
115
115
  "../../../../config/skill-state.js",
116
116
  "../../../../config/skills.js",
117
117
  "../../../../config/types.js",
118
+ // Same host module the retrospective job couples to (the `../../../`
119
+ // entry below): the sweep job and v2 router warm the guardian-delivery
120
+ // cache before their sync `resolveUserName` reads so worker-process
121
+ // prompts address the guardian instead of the default profile. No
122
+ // plugin-api equivalent.
123
+ "../../../../contacts/guardian-delivery-reader.js",
118
124
  "../../../../context/token-estimator.js",
119
125
  "../../../../daemon/conversation-error.js",
120
126
  "../../../../daemon/conversation-notices.js",
@@ -26,12 +26,6 @@ mock.module("../util/logger.js", () => createMockLoggerModule());
26
26
 
27
27
  // Stub the transitive imports so the module graph under test stops at
28
28
  // tool-side-effects.ts and never reaches the real sync publisher.
29
- mock.module("../channels/gateway-verification-sessions.js", () => ({
30
- findActiveSession: mock(() => Promise.resolve(null)),
31
- }));
32
- mock.module("../runtime/verification-outbound-actions.js", () => ({
33
- deliverVerificationSlack: mock(() => {}),
34
- }));
35
29
  mock.module("../media/app-icon-generator.js", () => ({
36
30
  generateAppIcon: mock(() => Promise.resolve()),
37
31
  }));
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tool post-execution side effects: failures in a hook must not surface to the
3
+ * caller.
4
+ *
5
+ * `runPostExecutionSideEffects` runs observation-and-notification work after a
6
+ * tool executor returns, so a hook that throws must be swallowed and logged
7
+ * rather than turning a successful tool call into a failed one.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mocks: must be set up before importing the module under test
14
+ // ---------------------------------------------------------------------------
15
+
16
+ // Stub out transitive dependencies to prevent import errors
17
+ mock.module("../bundler/app-compiler.js", () => ({
18
+ compileApp: mock(() => Promise.resolve({ ok: true })),
19
+ }));
20
+ mock.module("../media/app-icon-generator.js", () => ({
21
+ generateAppIcon: mock(() => Promise.resolve()),
22
+ }));
23
+ mock.module("../apps/app-store.js", () => ({
24
+ getApp: mock(() => null),
25
+ getAppDirPath: mock(() => ""),
26
+ getAppsDir: mock(() => ""),
27
+ resolveAppIdFromPath: mock(() => null),
28
+ resolveAppIdByDirName: mock(() => null),
29
+ resolveAppDir: mock(() => ({ dirName: "", dirPath: "" })),
30
+ slugify: mock((s: string) => s),
31
+ validateDirName: mock(() => {}),
32
+ generateAppDirName: mock(() => ""),
33
+ listApps: mock(() => []),
34
+ createApp: mock(() => ({})),
35
+ updateApp: mock(() => {}),
36
+ deleteApp: mock(() => {}),
37
+ getAppPreview: mock(() => null),
38
+ createAppRecord: mock(() => ({})),
39
+ getAppRecord: mock(() => null),
40
+ queryAppRecords: mock(() => []),
41
+ updateAppRecord: mock(() => {}),
42
+ deleteAppRecord: mock(() => {}),
43
+ listAppFiles: mock(() => []),
44
+ appFileExists: mock(() => false),
45
+ readAppFile: mock(() => ""),
46
+ writeAppFile: mock(() => {}),
47
+ editAppFile: mock(() => ({})),
48
+ inlineDistAssets: mock((_, html: string) => html),
49
+ addAppConversationId: mock(() => false),
50
+ linkAppToConversationLineage: mock(() => {}),
51
+ }));
52
+ mock.module("../services/published-app-updater.js", () => ({
53
+ updatePublishedAppDeployment: mock(() => Promise.resolve()),
54
+ }));
55
+ mock.module("../daemon/conversation-surfaces.js", () => ({
56
+ refreshSurfacesForApp: mock(() => {}),
57
+ }));
58
+ const mockIsDoordashCommand = mock(() => false);
59
+ const mockUpdateDoordashProgress = mock(() => {});
60
+ mock.module("../daemon/doordash-steps.js", () => ({
61
+ isDoordashCommand: mockIsDoordashCommand,
62
+ updateDoordashProgress: mockUpdateDoordashProgress,
63
+ }));
64
+
65
+ const mockLogWarn = mock((_obj: unknown, _msg: string) => {});
66
+ const mockLogInfo = mock((_obj: unknown, _msg: string) => {});
67
+ const mockLogError = mock((_obj: unknown, _msg: string) => {});
68
+ mock.module("../util/logger.js", () => ({
69
+ getLogger: () => ({
70
+ warn: mockLogWarn,
71
+ info: mockLogInfo,
72
+ error: mockLogError,
73
+ debug: () => {},
74
+ trace: () => {},
75
+ }),
76
+ }));
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Import after mocks: dynamic import ensures mock.module() calls above
80
+ // are registered before tool-side-effects.ts evaluates its top-level
81
+ // `const log = getLogger(...)`.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ import type { SideEffectContext } from "../daemon/tool-side-effects.js";
85
+
86
+ const { runPostExecutionSideEffects } =
87
+ await import("../daemon/tool-side-effects.js");
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Helpers
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const dummySideEffectCtx = {
94
+ ctx: {} as SideEffectContext["ctx"],
95
+ } satisfies SideEffectContext;
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Tests
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("doordash progress side effect", () => {
102
+ beforeEach(() => {
103
+ mockIsDoordashCommand.mockReset();
104
+ mockUpdateDoordashProgress.mockReset();
105
+ mockLogError.mockReset();
106
+ });
107
+
108
+ test("throwing updateDoordashProgress is swallowed and logged", async () => {
109
+ mockIsDoordashCommand.mockReturnValue(true);
110
+ mockUpdateDoordashProgress.mockImplementation(() => {
111
+ throw new Error("doordash boom");
112
+ });
113
+
114
+ await expect(
115
+ runPostExecutionSideEffects(
116
+ "bash",
117
+ { command: "order food" },
118
+ { content: "{}", isError: false },
119
+ dummySideEffectCtx,
120
+ ),
121
+ ).resolves.toBeUndefined();
122
+
123
+ expect(mockUpdateDoordashProgress).toHaveBeenCalledTimes(1);
124
+ expect(mockLogError).toHaveBeenCalledWith(
125
+ expect.objectContaining({ toolName: "bash" }),
126
+ expect.stringContaining("DoorDash progress update failed"),
127
+ );
128
+ });
129
+ });