@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.
- package/package.json +1 -1
- package/src/__tests__/conversation-initial-prompt.test.ts +51 -22
- package/src/__tests__/conversation-origin-channel-filter.test.ts +95 -0
- package/src/__tests__/guardian-outbound-http.test.ts +8 -8
- package/src/__tests__/persona-resolver.test.ts +154 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -0
- package/src/__tests__/tool-side-effects-documents.test.ts +0 -6
- package/src/__tests__/tool-side-effects-hook-failures.test.ts +129 -0
- package/src/__tests__/verification-outbound-delivery.test.ts +176 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/contacts/__tests__/guardian-delivery-reader.test.ts +16 -0
- package/src/contacts/guardian-delivery-reader.ts +27 -0
- package/src/daemon/conversation-initial-prompt.ts +16 -38
- package/src/daemon/identity-helpers.ts +38 -10
- package/src/daemon/tool-side-effects.ts +0 -90
- package/src/heartbeat/heartbeat-service.ts +6 -5
- package/src/notifications/__tests__/notification-utils.test.ts +1 -1
- package/src/persistence/conversation-queries.ts +27 -1
- package/src/persistence/conversation-types.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +1 -1
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +6 -5
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +7 -0
- package/src/plugins/defaults/memory/v2/router.ts +8 -0
- package/src/prompts/persona-resolver.ts +6 -2
- package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -0
- package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
- package/src/runtime/routes/channel-verification-routes.ts +2 -46
- package/src/runtime/verification-outbound-actions.ts +187 -345
- package/src/runtime/verification-templates.ts +2 -2
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +0 -357
package/package.json
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
});
|