@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -31,6 +31,10 @@ function runMcpAdd(name: string, args: string[]) {
31
31
  return runMcp('add', [name, ...args]);
32
32
  }
33
33
 
34
+ function runMcpRemove(name: string) {
35
+ return runMcp('remove', [name]);
36
+ }
37
+
34
38
  function writeConfig(config: Record<string, unknown>): void {
35
39
  writeFileSync(configPath, JSON.stringify(config), 'utf-8');
36
40
  }
@@ -256,3 +260,76 @@ describe('vellum mcp add', () => {
256
260
  expect(server.defaultRiskLevel).toBe('high');
257
261
  });
258
262
  });
263
+
264
+ describe('vellum mcp remove', () => {
265
+ beforeAll(() => {
266
+ testDataDir = join(tmpdir(), `vellum-mcp-remove-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
267
+ const workspaceDir = join(testDataDir, '.vellum', 'workspace');
268
+ mkdirSync(workspaceDir, { recursive: true });
269
+ configPath = join(workspaceDir, 'config.json');
270
+ writeConfig({});
271
+ });
272
+
273
+ afterAll(() => {
274
+ rmSync(testDataDir, { recursive: true, force: true });
275
+ });
276
+
277
+ beforeEach(() => {
278
+ writeConfig({});
279
+ });
280
+
281
+ test('removes an existing server', () => {
282
+ writeConfig({
283
+ mcp: {
284
+ servers: {
285
+ 'my-server': {
286
+ transport: { type: 'sse', url: 'https://example.com/sse' },
287
+ enabled: true,
288
+ defaultRiskLevel: 'high',
289
+ },
290
+ },
291
+ },
292
+ });
293
+
294
+ const { stdout, exitCode } = runMcpRemove('my-server');
295
+ expect(exitCode).toBe(0);
296
+ expect(stdout).toContain('Removed MCP server "my-server"');
297
+
298
+ const updated = readConfig();
299
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
300
+ expect(servers?.['my-server']).toBeUndefined();
301
+ });
302
+
303
+ test('errors when server does not exist', () => {
304
+ const { stderr, exitCode } = runMcpRemove('nonexistent');
305
+ expect(exitCode).toBe(1);
306
+ expect(stderr).toContain('not found');
307
+ });
308
+
309
+ test('preserves other servers when removing one', () => {
310
+ writeConfig({
311
+ mcp: {
312
+ servers: {
313
+ 'keep-me': {
314
+ transport: { type: 'streamable-http', url: 'https://example.com/keep' },
315
+ enabled: true,
316
+ defaultRiskLevel: 'low',
317
+ },
318
+ 'remove-me': {
319
+ transport: { type: 'sse', url: 'https://example.com/remove' },
320
+ enabled: true,
321
+ defaultRiskLevel: 'high',
322
+ },
323
+ },
324
+ },
325
+ });
326
+
327
+ const { exitCode } = runMcpRemove('remove-me');
328
+ expect(exitCode).toBe(0);
329
+
330
+ const updated = readConfig();
331
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
332
+ expect(servers?.['remove-me']).toBeUndefined();
333
+ expect(servers?.['keep-me']).toBeDefined();
334
+ });
335
+ });
@@ -80,9 +80,9 @@ mock.module('../runtime/gateway-client.js', () => ({
80
80
  },
81
81
  }));
82
82
 
83
+ import { listCanonicalGuardianRequests } from '../memory/canonical-guardian-store.js';
83
84
  import {
84
85
  createBinding,
85
- findPendingAccessRequestForRequester,
86
86
  } from '../memory/channel-guardian-store.js';
87
87
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
88
88
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
@@ -107,6 +107,8 @@ function resetState(): void {
107
107
  db.run('DELETE FROM channel_inbound_events');
108
108
  db.run('DELETE FROM conversations');
109
109
  db.run('DELETE FROM notification_events');
110
+ db.run('DELETE FROM canonical_guardian_requests');
111
+ db.run('DELETE FROM canonical_guardian_deliveries');
110
112
  emitSignalCalls.length = 0;
111
113
  deliverReplyCalls.length = 0;
112
114
  }
@@ -185,18 +187,18 @@ describe('non-member access request notification', () => {
185
187
  expect(payload.senderExternalUserId).toBe('user-unknown-456');
186
188
  expect(payload.senderName).toBe('Alice Unknown');
187
189
 
188
- // An approval request was created
189
- const pending = findPendingAccessRequestForRequester(
190
- 'self',
191
- 'telegram',
192
- 'user-unknown-456',
193
- 'ingress_access_request',
194
- );
195
- expect(pending).not.toBeNull();
196
- expect(pending!.status).toBe('pending');
197
- expect(pending!.requesterExternalUserId).toBe('user-unknown-456');
198
- expect(pending!.guardianExternalUserId).toBe('guardian-user-789');
199
- expect(pending!.toolName).toBe('ingress_access_request');
190
+ // A canonical access request was created
191
+ const pending = listCanonicalGuardianRequests({
192
+ status: 'pending',
193
+ requesterExternalUserId: 'user-unknown-456',
194
+ sourceChannel: 'telegram',
195
+ kind: 'access_request',
196
+ });
197
+ expect(pending.length).toBe(1);
198
+ expect(pending[0].status).toBe('pending');
199
+ expect(pending[0].requesterExternalUserId).toBe('user-unknown-456');
200
+ expect(pending[0].guardianExternalUserId).toBe('guardian-user-789');
201
+ expect(pending[0].toolName).toBe('ingress_access_request');
200
202
  });
201
203
 
202
204
  test('no duplicate approval requests for repeated messages from same non-member', async () => {
@@ -224,14 +226,14 @@ describe('non-member access request notification', () => {
224
226
  // Only one notification signal should be emitted (second is deduplicated)
225
227
  expect(emitSignalCalls.length).toBe(1);
226
228
 
227
- // Only one approval request should exist
228
- const pending = findPendingAccessRequestForRequester(
229
- 'self',
230
- 'telegram',
231
- 'user-unknown-456',
232
- 'ingress_access_request',
233
- );
234
- expect(pending).not.toBeNull();
229
+ // Only one canonical request should exist
230
+ const pending = listCanonicalGuardianRequests({
231
+ status: 'pending',
232
+ requesterExternalUserId: 'user-unknown-456',
233
+ sourceChannel: 'telegram',
234
+ kind: 'access_request',
235
+ });
236
+ expect(pending.length).toBe(1);
235
237
  });
236
238
 
237
239
  test('deny works without error when no guardian binding exists', async () => {
@@ -249,14 +251,14 @@ describe('non-member access request notification', () => {
249
251
  // No notification signal was emitted
250
252
  expect(emitSignalCalls.length).toBe(0);
251
253
 
252
- // No approval request was created
253
- const pending = findPendingAccessRequestForRequester(
254
- 'self',
255
- 'telegram',
256
- 'user-unknown-456',
257
- 'ingress_access_request',
258
- );
259
- expect(pending).toBeNull();
254
+ // No canonical request was created
255
+ const pending = listCanonicalGuardianRequests({
256
+ status: 'pending',
257
+ requesterExternalUserId: 'user-unknown-456',
258
+ sourceChannel: 'telegram',
259
+ kind: 'access_request',
260
+ });
261
+ expect(pending.length).toBe(0);
260
262
  });
261
263
 
262
264
  test('no notification when senderExternalUserId is absent', async () => {
@@ -5,7 +5,7 @@
5
5
  * decision-model call is unavailable.
6
6
  */
7
7
 
8
- import { describe, expect, mock, test } from 'bun:test';
8
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
9
9
 
10
10
  mock.module('../channels/config.js', () => ({
11
11
  getDeliverableChannels: () => ['vellum', 'telegram', 'sms'],
@@ -32,13 +32,16 @@ mock.module('../notifications/thread-candidates.js', () => ({
32
32
  serializeCandidatesForPrompt: () => undefined,
33
33
  }));
34
34
 
35
+ let configuredProvider: { sendMessage: () => Promise<unknown> } | null = null;
36
+ let extractedToolUse: unknown = null;
37
+
35
38
  mock.module('../providers/provider-send-message.js', () => ({
36
- getConfiguredProvider: () => null,
39
+ getConfiguredProvider: () => configuredProvider,
37
40
  createTimeout: () => ({
38
41
  signal: new AbortController().signal,
39
42
  cleanup: () => {},
40
43
  }),
41
- extractToolUse: () => null,
44
+ extractToolUse: () => extractedToolUse,
42
45
  userMessage: (text: string) => ({ role: 'user', content: text }),
43
46
  }));
44
47
 
@@ -75,6 +78,11 @@ function makeSignal(overrides?: Partial<NotificationSignal>): NotificationSignal
75
78
  }
76
79
 
77
80
  describe('notification decision fallback copy', () => {
81
+ beforeEach(() => {
82
+ configuredProvider = null;
83
+ extractedToolUse = null;
84
+ });
85
+
78
86
  test('uses human-friendly template copy for guardian.question', async () => {
79
87
  const signal = makeSignal();
80
88
  const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
@@ -85,4 +93,54 @@ describe('notification decision fallback copy', () => {
85
93
  expect(decision.renderedCopy.vellum?.title).not.toBe('guardian.question');
86
94
  expect(decision.renderedCopy.vellum?.body).not.toContain('Action required: guardian.question');
87
95
  });
96
+
97
+ test('enforces request-code instructions for guardian.question when requestCode exists', async () => {
98
+ const signal = makeSignal({
99
+ contextPayload: {
100
+ questionText: 'What is the gate code?',
101
+ requestCode: 'A1B2C3',
102
+ },
103
+ });
104
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
105
+
106
+ expect(decision.fallbackUsed).toBe(true);
107
+ expect(decision.renderedCopy.vellum?.body).toContain('A1B2C3');
108
+ expect(decision.renderedCopy.vellum?.body).toContain('approve');
109
+ expect(decision.renderedCopy.vellum?.body).toContain('reject');
110
+ });
111
+
112
+ test('enforcement appends explicit approve/reject instructions when LLM copy only mentions request code', async () => {
113
+ configuredProvider = {
114
+ sendMessage: async () => ({ content: [] }),
115
+ };
116
+ extractedToolUse = {
117
+ name: 'record_notification_decision',
118
+ input: {
119
+ shouldNotify: true,
120
+ selectedChannels: ['vellum'],
121
+ reasoningSummary: 'LLM decision',
122
+ renderedCopy: {
123
+ vellum: {
124
+ title: 'Guardian Question',
125
+ body: 'Use reference code A1B2C3 for this request.',
126
+ },
127
+ },
128
+ dedupeKey: 'guardian-question-test',
129
+ confidence: 0.9,
130
+ },
131
+ };
132
+
133
+ const signal = makeSignal({
134
+ contextPayload: {
135
+ questionText: 'What is the gate code?',
136
+ requestCode: 'A1B2C3',
137
+ },
138
+ });
139
+
140
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
141
+
142
+ expect(decision.fallbackUsed).toBe(false);
143
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
144
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
145
+ });
88
146
  });
@@ -55,6 +55,23 @@ describe('notification decision strategy', () => {
55
55
  expect(copy.vellum!.body).toContain('What is the gate code?');
56
56
  });
57
57
 
58
+ test('guardian.question template includes request-code instructions when present', () => {
59
+ const signal = makeSignal({
60
+ sourceEventName: 'guardian.question',
61
+ contextPayload: {
62
+ questionText: 'What is the gate code?',
63
+ requestCode: 'A1B2C3',
64
+ },
65
+ });
66
+
67
+ const copy = composeFallbackCopy(signal, channels);
68
+ expect(copy.vellum).toBeDefined();
69
+ expect(copy.vellum!.body).toContain('A1B2C3');
70
+ expect(copy.vellum!.body).toContain('approve');
71
+ expect(copy.vellum!.body).toContain('reject');
72
+ expect(copy.telegram!.deliveryText).toContain('A1B2C3');
73
+ });
74
+
58
75
  test('reminder.fired template uses message from payload', () => {
59
76
  const signal = makeSignal({
60
77
  sourceEventName: 'reminder.fired',
@@ -108,6 +108,8 @@ function ensureConversation(id: string): void {
108
108
 
109
109
  function resetTables(): void {
110
110
  const db = getDb();
111
+ db.run('DELETE FROM canonical_guardian_deliveries');
112
+ db.run('DELETE FROM canonical_guardian_requests');
111
113
  db.run('DELETE FROM guardian_action_deliveries');
112
114
  db.run('DELETE FROM guardian_action_requests');
113
115
  db.run('DELETE FROM call_pending_questions');
@@ -268,16 +270,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
268
270
 
269
271
  const db = getDb();
270
272
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
271
- const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
273
+ const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
272
274
  | { id: string }
273
275
  | undefined;
274
276
  const deliveries = raw.query(
275
- 'SELECT destination_channel, destination_conversation_id, destination_chat_id, destination_external_user_id, status FROM guardian_action_deliveries WHERE request_id = ? ORDER BY destination_channel ASC',
277
+ 'SELECT destination_channel, destination_conversation_id, destination_chat_id, status FROM canonical_guardian_deliveries WHERE request_id = ? ORDER BY destination_channel ASC',
276
278
  ).all(request!.id) as Array<{
277
279
  destination_channel: string;
278
280
  destination_conversation_id: string | null;
279
281
  destination_chat_id: string | null;
280
- destination_external_user_id: string | null;
281
282
  status: string;
282
283
  }>;
283
284
 
@@ -286,7 +287,6 @@ describe('ASK_GUARDIAN canonical notification path', () => {
286
287
  const vellum = deliveries.find((d) => d.destination_channel === 'vellum');
287
288
  expect(telegram).toBeDefined();
288
289
  expect(telegram!.destination_chat_id).toBe('tg-chat-abc');
289
- expect(telegram!.destination_external_user_id).toBe('tg-user-xyz');
290
290
  expect(telegram!.status).toBe('sent');
291
291
  expect(vellum).toBeDefined();
292
292
  expect(vellum!.destination_conversation_id).toBe('conv-guardian-vellum');
@@ -322,16 +322,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
322
322
 
323
323
  const db = getDb();
324
324
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
325
- const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
325
+ const request = raw.query('SELECT * FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
326
326
  | { id: string }
327
327
  | undefined;
328
328
  const vellumDelivery = raw.query(
329
- 'SELECT status, last_error FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
330
- ).get(request!.id, 'vellum') as { status: string; last_error: string | null } | undefined;
329
+ 'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
330
+ ).get(request!.id, 'vellum') as { status: string } | undefined;
331
331
 
332
332
  expect(vellumDelivery).toBeDefined();
333
333
  expect(vellumDelivery!.status).toBe('failed');
334
- expect(vellumDelivery!.last_error).toContain('No vellum delivery result');
335
334
  });
336
335
 
337
336
  test('context payload includes callSessionId and activeGuardianRequestCount for candidate-affinity', async () => {
@@ -426,11 +425,11 @@ describe('ASK_GUARDIAN canonical notification path', () => {
426
425
  pendingQuestion: pq2,
427
426
  });
428
427
 
429
- // Verify: two distinct guardian_action_requests exist
428
+ // Verify: two distinct canonical_guardian_requests exist
430
429
  const db = getDb();
431
430
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
432
431
  const requests = raw.query(
433
- 'SELECT id, question_text FROM guardian_action_requests WHERE call_session_id = ? ORDER BY created_at ASC',
432
+ 'SELECT id, question_text FROM canonical_guardian_requests WHERE call_session_id = ? ORDER BY created_at ASC',
434
433
  ).all(session.id) as Array<{ id: string; question_text: string }>;
435
434
  expect(requests).toHaveLength(2);
436
435
  expect(requests[0].question_text).toBe('Can they enter through the side gate?');
@@ -438,7 +437,7 @@ describe('ASK_GUARDIAN canonical notification path', () => {
438
437
 
439
438
  // Verify: each request has its own delivery row pointing to the shared conversation
440
439
  const deliveries = raw.query(
441
- 'SELECT request_id, destination_conversation_id, status FROM guardian_action_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
440
+ 'SELECT request_id, destination_conversation_id, status FROM canonical_guardian_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
442
441
  ).all(sharedConvId) as Array<{ request_id: string; destination_conversation_id: string; status: string }>;
443
442
  expect(deliveries).toHaveLength(2);
444
443
  expect(deliveries[0].request_id).toBe(requests[0].id);
@@ -478,16 +477,15 @@ describe('ASK_GUARDIAN canonical notification path', () => {
478
477
  // The dispatch should still create a failed fallback delivery row
479
478
  const db = getDb();
480
479
  const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
481
- const request = raw.query('SELECT id FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
480
+ const request = raw.query('SELECT id FROM canonical_guardian_requests WHERE call_session_id = ?').get(session.id) as
482
481
  | { id: string }
483
482
  | undefined;
484
483
  expect(request).toBeDefined();
485
484
 
486
485
  const delivery = raw.query(
487
- 'SELECT status, last_error FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
488
- ).get(request!.id, 'vellum') as { status: string; last_error: string | null } | undefined;
486
+ 'SELECT status FROM canonical_guardian_deliveries WHERE request_id = ? AND destination_channel = ?',
487
+ ).get(request!.id, 'vellum') as { status: string } | undefined;
489
488
  expect(delivery).toBeDefined();
490
489
  expect(delivery!.status).toBe('failed');
491
- expect(delivery!.last_error).toContain('No vellum delivery result');
492
490
  });
493
491
  });
@@ -6,19 +6,21 @@ import { describe, expect,test } from 'bun:test';
6
6
  const templatesDir = join(import.meta.dirname, '..', 'config', 'templates');
7
7
  const bootstrap = readFileSync(join(templatesDir, 'BOOTSTRAP.md'), 'utf-8');
8
8
  const identity = readFileSync(join(templatesDir, 'IDENTITY.md'), 'utf-8');
9
+ const user = readFileSync(join(templatesDir, 'USER.md'), 'utf-8');
9
10
 
10
11
  describe('onboarding template contracts', () => {
11
12
  describe('BOOTSTRAP.md', () => {
12
13
  test('contains identity question prompts', () => {
13
14
  const lower = bootstrap.toLowerCase();
14
15
  expect(lower).toContain('who am i');
15
- expect(lower).toContain('who are you');
16
16
  });
17
17
 
18
- test('uses "personality" for the personality step', () => {
19
- expect(bootstrap).toContain('What is my personality?');
20
- // Should not use "character" or "vibe" as a field/step label
21
- expect(bootstrap).not.toMatch(/what is my (character|vibe)/i);
18
+ test('infers personality indirectly instead of asking directly', () => {
19
+ const lower = bootstrap.toLowerCase();
20
+ // Personality step must instruct indirect/organic discovery
21
+ expect(lower).toContain('personality');
22
+ expect(lower).toContain('indirectly');
23
+ expect(lower).toContain('vibe');
22
24
  });
23
25
 
24
26
  test('contains emoji auto-selection with change-later instruction', () => {
@@ -27,30 +29,106 @@ describe('onboarding template contracts', () => {
27
29
  expect(lower).toContain('change it later');
28
30
  });
29
31
 
30
- test('contains the Home Base handoff format', () => {
31
- expect(bootstrap).toMatch(/came up with X ideas/i);
32
- expect(bootstrap).toMatch(/check this out/i);
33
- });
34
-
35
- test('mentions avatar evolution instruction', () => {
32
+ test('creates Home Base silently in the background', () => {
36
33
  const lower = bootstrap.toLowerCase();
37
- expect(lower).toContain('avatar will start to reflect');
38
- expect(lower).toContain('happens automatically');
34
+ expect(lower).toContain('app_create');
35
+ expect(lower).toContain('set_as_home_base');
36
+ // Must NOT open or announce it
37
+ expect(lower).toContain('do not open it with `app_open`');
38
+ expect(lower).toContain('do not announce it');
39
39
  });
40
40
 
41
41
  test('contains naming intent markers so the first reply includes naming cues', () => {
42
42
  const lower = bootstrap.toLowerCase();
43
43
  // The template must prompt the assistant to ask about names.
44
- // These keywords align with the client-side naming intent heuristic
45
- // (ChatViewModel.replyContainsNamingIntent) so that the first reply
46
- // naturally passes the quality check without triggering a corrective nudge.
47
44
  expect(lower).toContain('name');
48
- expect(lower).toContain('call');
49
- // The example first message should include a naming question
50
- expect(lower).toContain('what should i call myself');
51
- // The conversation sequence must include identity/naming as the first step
45
+ // The first step should be about locking in the assistant's name
46
+ expect(lower).toContain('lock in your name');
47
+ // The conversation sequence must include identity/naming
52
48
  expect(lower).toContain('who am i');
53
- expect(lower).toContain('who are you');
49
+ });
50
+
51
+ test('asks user name AFTER assistant identity is established', () => {
52
+ // Step 1 is locking in the assistant's name, step 3 is asking the user's name
53
+ const assistantNameIdx = bootstrap.indexOf('Lock in your name.');
54
+ const userNameIdx = bootstrap.indexOf('who am I talking to?');
55
+ expect(assistantNameIdx).toBeGreaterThan(-1);
56
+ expect(userNameIdx).toBeGreaterThan(-1);
57
+ expect(assistantNameIdx).toBeLessThan(userNameIdx);
58
+ });
59
+
60
+ test('gathers user context: work role, hobbies, daily tools', () => {
61
+ const lower = bootstrap.toLowerCase();
62
+ expect(lower).toContain('work');
63
+ expect(lower).toContain('hobbies');
64
+ expect(lower).toContain('tools');
65
+ });
66
+
67
+ test('shows exactly 2 suggestions via ui_show card with relay_prompt actions', () => {
68
+ expect(bootstrap).toContain('ui_show');
69
+ expect(bootstrap).toContain('exactly 2');
70
+ // Must use card surface with relay_prompt action buttons
71
+ expect(bootstrap).toContain('surface_type: "card"');
72
+ expect(bootstrap).toContain('relay_prompt');
73
+ });
74
+
75
+ test('contains completion gate with all required conditions', () => {
76
+ const lower = bootstrap.toLowerCase();
77
+ expect(lower).toContain('completion gate');
78
+ expect(lower).toContain('do not delete this file');
79
+ // Assistant name is hard-required
80
+ expect(lower).toContain('you have a name');
81
+ expect(lower).toContain('hard requirement');
82
+ expect(lower).toContain('vibe');
83
+ // User detail fields must be resolved (provided, inferred, or declined)
84
+ expect(lower).toContain('resolved');
85
+ expect(lower).toContain('work role');
86
+ expect(lower).toContain('2 suggestions shown');
87
+ expect(lower).toContain('selected one, deferred both');
88
+ expect(lower).toContain('home base');
89
+ });
90
+
91
+ test('contains privacy/refusal policy', () => {
92
+ const lower = bootstrap.toLowerCase();
93
+ // Must have a privacy section
94
+ expect(lower).toContain('privacy');
95
+ // Assistant name is hard-required, user details are best-effort
96
+ expect(lower).toContain('hard-required');
97
+ expect(lower).toContain('best-effort');
98
+ // Refusal is a valid resolution
99
+ expect(lower).toContain('declined');
100
+ expect(lower).toContain('do not push');
101
+ });
102
+
103
+ test('defines resolved as provided, inferred, or declined', () => {
104
+ const lower = bootstrap.toLowerCase();
105
+ // The template must define what "resolved" means
106
+ expect(lower).toContain('resolved');
107
+ expect(lower).toContain('inferred');
108
+ expect(lower).toContain('declined');
109
+ });
110
+
111
+ test('preserves no em dashes instruction', () => {
112
+ const lower = bootstrap.toLowerCase();
113
+ expect(lower).toContain('em dashes');
114
+ });
115
+
116
+ test('preserves no technical jargon instruction', () => {
117
+ const lower = bootstrap.toLowerCase();
118
+ expect(lower).toContain('technical jargon');
119
+ expect(lower).toContain('system internals');
120
+ });
121
+
122
+ test('preserves comment line format instruction', () => {
123
+ // The template must start with the comment format explanation
124
+ expect(bootstrap).toMatch(/^_ Lines starting with _/);
125
+ });
126
+
127
+ test('instructs saving to IDENTITY.md, USER.md, and SOUL.md via file_edit', () => {
128
+ expect(bootstrap).toContain('IDENTITY.md');
129
+ expect(bootstrap).toContain('USER.md');
130
+ expect(bootstrap).toContain('SOUL.md');
131
+ expect(bootstrap).toContain('file_edit');
54
132
  });
55
133
  });
56
134
 
@@ -71,4 +149,21 @@ describe('onboarding template contracts', () => {
71
149
  expect(identity).toContain('**Style tendency:**');
72
150
  });
73
151
  });
152
+
153
+ describe('USER.md', () => {
154
+ test('contains onboarding snapshot with all required fields', () => {
155
+ expect(user).toContain('Preferred name/reference:');
156
+ expect(user).toContain('Goals:');
157
+ expect(user).toContain('Locale:');
158
+ expect(user).toContain('Work role:');
159
+ expect(user).toContain('Hobbies/fun:');
160
+ expect(user).toContain('Daily tools:');
161
+ });
162
+
163
+ test('documents resolved-field status conventions', () => {
164
+ const lower = user.toLowerCase();
165
+ expect(lower).toContain('declined_by_user');
166
+ expect(lower).toContain('resolved');
167
+ });
168
+ });
74
169
  });
@@ -346,4 +346,63 @@ describe('Secret scanner executor integration', () => {
346
346
  expect(types).toContain('AWS Access Key');
347
347
  expect(types).toContain('GitHub Token');
348
348
  });
349
+
350
+ // -----------------------------------------------------------------------
351
+ // sensitive output directive extraction runs before secret detection
352
+ // -----------------------------------------------------------------------
353
+ test('sensitive output directives are stripped and replaced with placeholders before secret scanning', async () => {
354
+ mockConfig.secretDetection.action = 'redact';
355
+ const rawToken = 'xK9mP2vL4nR7wQ3j';
356
+ fakeToolResult = {
357
+ content: `<vellum-sensitive-output kind="invite_code" value="${rawToken}" />\nhttps://t.me/bot?start=iv_${rawToken}`,
358
+ isError: false,
359
+ };
360
+
361
+ const lifecycleEvents: ToolLifecycleEvent[] = [];
362
+ const ctx = makeContext({
363
+ onToolLifecycleEvent: (event) => {
364
+ lifecycleEvents.push(event);
365
+ },
366
+ });
367
+
368
+ const result = await executor.execute('bash', {}, ctx);
369
+
370
+ // The raw token should NOT appear in the result content
371
+ expect(result.content).not.toContain(rawToken);
372
+ // The directive tag should be fully stripped
373
+ expect(result.content).not.toContain('<vellum-sensitive-output');
374
+ // A placeholder should be present instead
375
+ expect(result.content).toMatch(/VELLUM_ASSISTANT_INVITE_CODE_[A-Z0-9]{8}/);
376
+ // Sensitive bindings should be attached for downstream substitution
377
+ expect(result.sensitiveBindings).toBeDefined();
378
+ expect(result.sensitiveBindings).toHaveLength(1);
379
+ expect(result.sensitiveBindings![0].value).toBe(rawToken);
380
+ expect(result.sensitiveBindings![0].kind).toBe('invite_code');
381
+ });
382
+
383
+ test('sensitive output bindings are NOT present in lifecycle event result', async () => {
384
+ mockConfig.secretDetection.action = 'warn';
385
+ const rawToken = 'testToken999';
386
+ fakeToolResult = {
387
+ content: `<vellum-sensitive-output kind="invite_code" value="${rawToken}" />\nhttps://t.me/bot?start=iv_${rawToken}`,
388
+ isError: false,
389
+ };
390
+
391
+ const lifecycleEvents: ToolLifecycleEvent[] = [];
392
+ const ctx = makeContext({
393
+ onToolLifecycleEvent: (event) => {
394
+ lifecycleEvents.push(event);
395
+ },
396
+ });
397
+
398
+ await executor.execute('bash', {}, ctx);
399
+
400
+ // Find the 'executed' lifecycle event
401
+ const executedEvents = lifecycleEvents.filter(
402
+ (e): e is Extract<ToolLifecycleEvent, { type: 'executed' }> => e.type === 'executed',
403
+ );
404
+ expect(executedEvents).toHaveLength(1);
405
+ // The emitted result must NOT contain sensitiveBindings
406
+ expect((executedEvents[0].result as unknown as Record<string, unknown>).sensitiveBindings).toBeUndefined();
407
+ });
349
408
  });
@@ -675,6 +675,14 @@ describe('entropy-based detection', () => {
675
675
  expect(entropyMatches.length).toBeGreaterThanOrEqual(1);
676
676
  }
677
677
  });
678
+
679
+ test('does not redact Telegram invite deep links', () => {
680
+ const invite = 'https://t.me/credence_the_bot?start=iv_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_ABCDE';
681
+ const input = `Here is your invite link: ${invite}`;
682
+ const matches = scanText(input);
683
+ expect(matches).toHaveLength(0);
684
+ expect(redactSecrets(input)).toBe(input);
685
+ });
678
686
  });
679
687
 
680
688
  // ---------------------------------------------------------------------------