@vellumai/assistant 0.4.57 → 0.5.1

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/assistant-feature-flags-integration.test.ts +7 -9
  3. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
  4. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  5. package/src/__tests__/encrypted-store.test.ts +24 -12
  6. package/src/__tests__/file-read-tool.test.ts +40 -0
  7. package/src/__tests__/filesystem-tools.test.ts +4 -2
  8. package/src/__tests__/history-repair.test.ts +71 -0
  9. package/src/__tests__/host-file-read-tool.test.ts +87 -0
  10. package/src/__tests__/identity-intro-cache.test.ts +209 -0
  11. package/src/__tests__/model-intents.test.ts +1 -1
  12. package/src/__tests__/non-member-access-request.test.ts +3 -3
  13. package/src/__tests__/skill-feature-flags-integration.test.ts +18 -17
  14. package/src/__tests__/skill-feature-flags.test.ts +13 -13
  15. package/src/__tests__/skill-load-feature-flag.test.ts +4 -4
  16. package/src/__tests__/skill-memory.test.ts +14 -12
  17. package/src/__tests__/system-prompt.test.ts +8 -0
  18. package/src/config/feature-flag-registry.json +9 -1
  19. package/src/daemon/conversation-agent-loop-handlers.ts +2 -39
  20. package/src/daemon/conversation-runtime-assembly.ts +4 -3
  21. package/src/daemon/history-repair.ts +28 -8
  22. package/src/daemon/trace-emitter.ts +3 -2
  23. package/src/memory/search/staleness.ts +4 -1
  24. package/src/notifications/decision-engine.ts +43 -2
  25. package/src/notifications/emit-signal.ts +1 -0
  26. package/src/permissions/checker.ts +0 -20
  27. package/src/prompts/system-prompt.ts +2 -0
  28. package/src/prompts/templates/BOOTSTRAP.md +10 -4
  29. package/src/prompts/templates/IDENTITY.md +1 -2
  30. package/src/providers/anthropic/client.ts +5 -17
  31. package/src/runtime/access-request-helper.ts +15 -1
  32. package/src/runtime/guardian-vellum-migration.ts +1 -3
  33. package/src/runtime/routes/btw-routes.ts +84 -0
  34. package/src/runtime/routes/identity-intro-cache.ts +105 -0
  35. package/src/runtime/routes/identity-routes.ts +51 -0
  36. package/src/runtime/routes/settings-routes.ts +1 -1
  37. package/src/security/encrypted-store.ts +1 -2
  38. package/src/skills/skill-memory.ts +5 -3
  39. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  40. package/src/telemetry/usage-telemetry-reporter.ts +2 -0
  41. package/src/tools/filesystem/read.ts +14 -3
  42. package/src/tools/host-filesystem/read.ts +17 -1
  43. package/src/tools/shared/filesystem/format-diff.ts +4 -16
  44. package/src/util/pricing.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.57",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -228,7 +228,7 @@ describe("buildSystemPrompt assistant feature flag filtering", () => {
228
228
  expect(result).not.toContain(`**${DECLARED_SKILL_ID}**`);
229
229
  });
230
230
 
231
- test("declared skills hidden when no flag overrides set (registry defaults to false)", () => {
231
+ test("contacts visible but email-channel hidden when no flag overrides set (contacts defaults true, email-channel defaults false)", () => {
232
232
  createSkillOnDisk(
233
233
  DECLARED_SKILL_ID,
234
234
  "Contacts",
@@ -263,8 +263,8 @@ describe("buildSystemPrompt assistant feature flag filtering", () => {
263
263
 
264
264
  const result = buildSystemPrompt();
265
265
 
266
- // Both skills declare feature flags with registry defaultEnabled: false
267
- expect(result).not.toContain(`**${DECLARED_SKILL_ID}**`);
266
+ // contacts defaults to true, email-channel defaults to false
267
+ expect(result).toContain(`**${DECLARED_SKILL_ID}**`);
268
268
  expect(result).not.toContain("**email-channel**");
269
269
  });
270
270
 
@@ -466,12 +466,10 @@ describe("isAssistantFeatureFlagEnabled", () => {
466
466
 
467
467
  test("missing persisted value falls back to defaults registry defaultEnabled", () => {
468
468
  // No explicit config at all — should fall back to defaults registry
469
- // which has defaultEnabled: false for contacts
469
+ // which has defaultEnabled: true for contacts
470
470
  const config = {} as any;
471
471
 
472
- expect(isAssistantFeatureFlagEnabled(DECLARED_FLAG_KEY, config)).toBe(
473
- false,
474
- );
472
+ expect(isAssistantFeatureFlagEnabled(DECLARED_FLAG_KEY, config)).toBe(true);
475
473
  });
476
474
 
477
475
  test("unknown flag defaults to true when no persisted override", () => {
@@ -510,7 +508,7 @@ describe("isAssistantFeatureFlagEnabled with skillFlagKey", () => {
510
508
  ).toBe(false);
511
509
  });
512
510
 
513
- test("disabled when no override set (registry default is false)", () => {
511
+ test("enabled when no override set (registry default is true)", () => {
514
512
  const config = {} as any;
515
513
 
516
514
  expect(
@@ -518,6 +516,6 @@ describe("isAssistantFeatureFlagEnabled with skillFlagKey", () => {
518
516
  skillFlagKey({ featureFlag: DECLARED_FLAG_ID })!,
519
517
  config,
520
518
  ),
521
- ).toBe(false);
519
+ ).toBe(true);
522
520
  });
523
521
  });
@@ -1055,39 +1055,46 @@ describe("applyRuntimeInjections with inboundActorContext", () => {
1055
1055
 
1056
1056
  describe("buildTurnContextBlock (channel-only)", () => {
1057
1057
  test("collapses to single field when all channels match", () => {
1058
- const block = buildTurnContextBlock({
1059
- turnContext: {
1060
- userMessageChannel: "telegram",
1061
- assistantMessageChannel: "telegram",
1058
+ const block = buildTurnContextBlock(
1059
+ {
1060
+ turnContext: {
1061
+ userMessageChannel: "telegram",
1062
+ assistantMessageChannel: "telegram",
1063
+ },
1064
+ conversationOriginChannel: "telegram",
1062
1065
  },
1063
- conversationOriginChannel: "telegram",
1064
- }, undefined);
1066
+ undefined,
1067
+ );
1065
1068
  expect(block).toBe(
1066
- "<turn_context>\n" +
1067
- "channel: telegram\n" +
1068
- "</turn_context>",
1069
+ "<turn_context>\n" + "channel: telegram\n" + "</turn_context>",
1069
1070
  );
1070
1071
  });
1071
1072
 
1072
1073
  test('uses "unknown" when conversationOriginChannel is null', () => {
1073
- const block = buildTurnContextBlock({
1074
- turnContext: {
1075
- userMessageChannel: "vellum",
1076
- assistantMessageChannel: "vellum",
1074
+ const block = buildTurnContextBlock(
1075
+ {
1076
+ turnContext: {
1077
+ userMessageChannel: "vellum",
1078
+ assistantMessageChannel: "vellum",
1079
+ },
1080
+ conversationOriginChannel: null,
1077
1081
  },
1078
- conversationOriginChannel: null,
1079
- }, undefined);
1082
+ undefined,
1083
+ );
1080
1084
  expect(block).toContain("conversation_origin_channel: unknown");
1081
1085
  });
1082
1086
 
1083
1087
  test("handles mixed channels", () => {
1084
- const block = buildTurnContextBlock({
1085
- turnContext: {
1086
- userMessageChannel: "telegram",
1087
- assistantMessageChannel: "vellum",
1088
+ const block = buildTurnContextBlock(
1089
+ {
1090
+ turnContext: {
1091
+ userMessageChannel: "telegram",
1092
+ assistantMessageChannel: "vellum",
1093
+ },
1094
+ conversationOriginChannel: "vellum",
1088
1095
  },
1089
- conversationOriginChannel: "vellum",
1090
- }, undefined);
1096
+ undefined,
1097
+ );
1091
1098
  expect(block).toContain("user_message_channel: telegram");
1092
1099
  expect(block).toContain("assistant_message_channel: vellum");
1093
1100
  expect(block).toContain("conversation_origin_channel: vellum");
@@ -154,16 +154,16 @@ describe("CES flags do not affect unrelated flags", () => {
154
154
  ).toBe(true);
155
155
  });
156
156
 
157
- test("enabling all CES flags does not change contacts flag (defaultEnabled: false)", () => {
157
+ test("enabling all CES flags does not change contacts flag (defaultEnabled: true)", () => {
158
158
  const overrides: Record<string, boolean> = {};
159
159
  for (const key of ALL_CES_FLAG_KEYS) {
160
160
  overrides[key] = true;
161
161
  }
162
162
  const config = makeConfig(overrides);
163
163
 
164
- // contacts defaults to false in the registry and should stay false
164
+ // contacts defaults to true in the registry and should stay true
165
165
  expect(
166
166
  isAssistantFeatureFlagEnabled("feature_flags.contacts.enabled", config),
167
- ).toBe(false);
167
+ ).toBe(true);
168
168
  });
169
169
  });
@@ -1,8 +1,4 @@
1
- import {
2
- createCipheriv,
3
- pbkdf2Sync,
4
- randomBytes,
5
- } from "node:crypto";
1
+ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
6
2
  import {
7
3
  chmodSync,
8
4
  existsSync,
@@ -72,11 +68,23 @@ const SALT_LENGTH = 32;
72
68
  /** Local copy of the legacy machine entropy derivation (the export was removed). */
73
69
  function legacyMachineEntropy(): string {
74
70
  const parts: string[] = [];
75
- try { parts.push(hostname()); } catch { parts.push("unknown-host"); }
76
- try { parts.push(userInfo().username); } catch { parts.push("unknown-user"); }
71
+ try {
72
+ parts.push(hostname());
73
+ } catch {
74
+ parts.push("unknown-host");
75
+ }
76
+ try {
77
+ parts.push(userInfo().username);
78
+ } catch {
79
+ parts.push("unknown-user");
80
+ }
77
81
  parts.push(process.platform);
78
82
  parts.push(process.arch);
79
- try { parts.push(userInfo().homedir); } catch { parts.push("/tmp"); }
83
+ try {
84
+ parts.push(userInfo().homedir);
85
+ } catch {
86
+ parts.push("/tmp");
87
+ }
80
88
  return parts.join(":");
81
89
  }
82
90
 
@@ -346,7 +354,8 @@ describe("encrypted-store", () => {
346
354
  // Create a v1 store using legacy encryption
347
355
  const salt = randomBytes(SALT_LENGTH);
348
356
  const legacyKey = legacyDeriveKey(salt);
349
- const entries: Record<string, { iv: string; tag: string; data: string }> = {};
357
+ const entries: Record<string, { iv: string; tag: string; data: string }> =
358
+ {};
350
359
  entries["api-key"] = legacyEncrypt("secret-123", legacyKey);
351
360
  entries["other-key"] = legacyEncrypt("secret-456", legacyKey);
352
361
  writeV1Store(STORE_PATH, salt, entries);
@@ -372,7 +381,8 @@ describe("encrypted-store", () => {
372
381
  // Create a v1 store
373
382
  const salt = randomBytes(SALT_LENGTH);
374
383
  const legacyKey = legacyDeriveKey(salt);
375
- const entries: Record<string, { iv: string; tag: string; data: string }> = {};
384
+ const entries: Record<string, { iv: string; tag: string; data: string }> =
385
+ {};
376
386
  entries["my-key"] = legacyEncrypt("my-value", legacyKey);
377
387
  writeV1Store(STORE_PATH, salt, entries);
378
388
 
@@ -393,7 +403,8 @@ describe("encrypted-store", () => {
393
403
  // Create a v1 store with one good entry and one tampered entry
394
404
  const salt = randomBytes(SALT_LENGTH);
395
405
  const legacyKey = legacyDeriveKey(salt);
396
- const entries: Record<string, { iv: string; tag: string; data: string }> = {};
406
+ const entries: Record<string, { iv: string; tag: string; data: string }> =
407
+ {};
397
408
  entries["good"] = legacyEncrypt("good-value", legacyKey);
398
409
  entries["bad"] = legacyEncrypt("bad-value", legacyKey);
399
410
  // Tamper with the bad entry
@@ -423,7 +434,8 @@ describe("encrypted-store", () => {
423
434
  // write v1 store + store.key but leave store as v1
424
435
  const salt = randomBytes(SALT_LENGTH);
425
436
  const legacyKey = legacyDeriveKey(salt);
426
- const entries: Record<string, { iv: string; tag: string; data: string }> = {};
437
+ const entries: Record<string, { iv: string; tag: string; data: string }> =
438
+ {};
427
439
  entries["test-key"] = legacyEncrypt("test-value", legacyKey);
428
440
  writeV1Store(STORE_PATH, salt, entries);
429
441
 
@@ -177,3 +177,43 @@ describe("file_read image support", () => {
177
177
  expect(result.content).toContain("outside the working directory");
178
178
  });
179
179
  });
180
+
181
+ // ── Out-of-bounds hint for host_file_read ─────────────────────────────
182
+
183
+ describe("file_read out-of-bounds hint", () => {
184
+ test("suggests host_file_read for out-of-bounds text file path", async () => {
185
+ const dir = makeTempDir();
186
+
187
+ const result = await fileReadTool.execute(
188
+ { path: "/etc/passwd" },
189
+ makeContext(dir),
190
+ );
191
+
192
+ expect(result.isError).toBe(true);
193
+ expect(result.content).toContain("host_file_read");
194
+ });
195
+
196
+ test("suggests host_file_read for out-of-bounds image file path", async () => {
197
+ const dir = makeTempDir();
198
+
199
+ const result = await fileReadTool.execute(
200
+ { path: "/Users/someone/Desktop/screenshot.png" },
201
+ makeContext(dir),
202
+ );
203
+
204
+ expect(result.isError).toBe(true);
205
+ expect(result.content).toContain("host_file_read");
206
+ });
207
+
208
+ test("does not suggest host_file_read for missing file within sandbox", async () => {
209
+ const dir = makeTempDir();
210
+
211
+ const result = await fileReadTool.execute(
212
+ { path: "nonexistent.txt" },
213
+ makeContext(dir),
214
+ );
215
+
216
+ expect(result.isError).toBe(true);
217
+ expect(result.content).not.toContain("host_file_read");
218
+ });
219
+ });
@@ -325,12 +325,14 @@ describe("formatEditDiff", () => {
325
325
  expect(result).not.toContain("+ ");
326
326
  });
327
327
 
328
- test("truncates long diffs beyond 8 lines", () => {
328
+ test("shows all diff lines without truncation", () => {
329
329
  const longOld = Array.from({ length: 12 }, (_, i) => `old-line-${i}`).join(
330
330
  "\n",
331
331
  );
332
332
  const result = formatEditDiff(longOld, "short");
333
- expect(result).toContain("more lines");
333
+ expect(result).not.toContain("more lines");
334
+ expect(result).toContain("old-line-11");
335
+ expect(result).toContain("+ short");
334
336
  });
335
337
  });
336
338
 
@@ -588,6 +588,77 @@ describe("repairHistory", () => {
588
588
  });
589
589
  });
590
590
 
591
+ test("synthetic web_search_tool_result is placed immediately after its server_tool_use, not at end", () => {
592
+ // Regression: synthetic results appended to the end of the content array
593
+ // get separated from their server_tool_use by ensureToolPairing's split
594
+ // at tool_use boundaries, causing the API to reject with "web_search
595
+ // tool use without a corresponding web_search_tool_result block".
596
+ const messages: Message[] = [
597
+ { role: "user", content: [{ type: "text", text: "Search and act" }] },
598
+ {
599
+ role: "assistant",
600
+ content: [
601
+ { type: "text", text: "Let me search" },
602
+ {
603
+ type: "server_tool_use",
604
+ id: "stu_1",
605
+ name: "web_search",
606
+ input: { query: "openai" },
607
+ },
608
+ {
609
+ type: "server_tool_use",
610
+ id: "stu_2",
611
+ name: "web_search",
612
+ input: { query: "anthropic" },
613
+ },
614
+ { type: "text", text: "Based on my research" },
615
+ {
616
+ type: "tool_use",
617
+ id: "tu_1",
618
+ name: "skill_load",
619
+ input: { skill: "app-builder" },
620
+ },
621
+ ],
622
+ },
623
+ {
624
+ role: "user",
625
+ content: [
626
+ {
627
+ type: "tool_result",
628
+ tool_use_id: "tu_1",
629
+ content: "Skill loaded",
630
+ },
631
+ ],
632
+ },
633
+ ];
634
+
635
+ const { messages: repaired, stats } = repairHistory(messages);
636
+
637
+ expect(stats.missingToolResultsInserted).toBe(2);
638
+
639
+ const assistantMsg = repaired[1];
640
+ // Synthetic results must appear immediately after their server_tool_use,
641
+ // NOT after the tool_use block at the end
642
+ const blockTypes = assistantMsg.content.map((b) => b.type);
643
+ expect(blockTypes).toEqual([
644
+ "text",
645
+ "server_tool_use",
646
+ "web_search_tool_result", // right after stu_1
647
+ "server_tool_use",
648
+ "web_search_tool_result", // right after stu_2
649
+ "text",
650
+ "tool_use",
651
+ ]);
652
+
653
+ // Verify the pairings are correct
654
+ expect(
655
+ (assistantMsg.content[2] as { tool_use_id: string }).tool_use_id,
656
+ ).toBe("stu_1");
657
+ expect(
658
+ (assistantMsg.content[4] as { tool_use_id: string }).tool_use_id,
659
+ ).toBe("stu_2");
660
+ });
661
+
591
662
  test("downgrades type-mismatched tool_result for server_tool_use", () => {
592
663
  // A tool_result in the user message for a server_tool_use ID is orphaned —
593
664
  // server-side results belong in the assistant message
@@ -8,6 +8,12 @@ import type { ToolContext } from "../tools/types.js";
8
8
 
9
9
  const testDirs: string[] = [];
10
10
 
11
+ function makeTempDir(): string {
12
+ const dir = mkdtempSync(join(tmpdir(), "host-file-read-test-"));
13
+ testDirs.push(dir);
14
+ return dir;
15
+ }
16
+
11
17
  function makeContext(): ToolContext {
12
18
  return {
13
19
  workingDir: "/tmp",
@@ -22,6 +28,18 @@ afterEach(() => {
22
28
  }
23
29
  });
24
30
 
31
+ // Minimal valid JPEG: FF D8 FF E0 header
32
+ const JPEG_HEADER = Buffer.from([
33
+ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01,
34
+ 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
35
+ ]);
36
+
37
+ // Minimal PNG header
38
+ const PNG_HEADER = Buffer.from([
39
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
40
+ 0x48, 0x44, 0x52,
41
+ ]);
42
+
25
43
  describe("host_file_read tool", () => {
26
44
  test("rejects relative paths", async () => {
27
45
  const result = await hostFileReadTool.execute(
@@ -145,3 +163,72 @@ describe("host_file_read tool", () => {
145
163
  expect(result.content).toContain("symlink-content");
146
164
  });
147
165
  });
166
+
167
+ describe("host_file_read image support", () => {
168
+ test("returns image content block for .png file", async () => {
169
+ const dir = makeTempDir();
170
+ const filePath = join(dir, "screenshot.png");
171
+ writeFileSync(filePath, PNG_HEADER);
172
+
173
+ const result = await hostFileReadTool.execute(
174
+ { path: filePath },
175
+ makeContext(),
176
+ );
177
+
178
+ expect(result.isError).toBe(false);
179
+ expect(result.content).toContain("Image loaded");
180
+ expect(result.content).toContain("image/png");
181
+ expect((result as any).contentBlocks).toBeDefined();
182
+ expect((result as any).contentBlocks[0].type).toBe("image");
183
+ expect((result as any).contentBlocks[0].source.media_type).toBe(
184
+ "image/png",
185
+ );
186
+ });
187
+
188
+ test("returns correct media type for .jpg file", async () => {
189
+ const dir = makeTempDir();
190
+ const filePath = join(dir, "photo.jpg");
191
+ writeFileSync(filePath, JPEG_HEADER);
192
+
193
+ const result = await hostFileReadTool.execute(
194
+ { path: filePath },
195
+ makeContext(),
196
+ );
197
+
198
+ expect(result.isError).toBe(false);
199
+ expect(result.content).toContain("Image loaded");
200
+ expect(result.content).toContain("image/jpeg");
201
+ expect((result as any).contentBlocks).toBeDefined();
202
+ expect((result as any).contentBlocks[0].type).toBe("image");
203
+ expect((result as any).contentBlocks[0].source.media_type).toBe(
204
+ "image/jpeg",
205
+ );
206
+ });
207
+
208
+ test("returns error for non-existent image path", async () => {
209
+ const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.png`);
210
+ const result = await hostFileReadTool.execute(
211
+ { path: filePath },
212
+ makeContext(),
213
+ );
214
+
215
+ expect(result.isError).toBe(true);
216
+ expect(result.content).toContain("file not found");
217
+ });
218
+
219
+ test("text file still works as before (regression)", async () => {
220
+ const dir = makeTempDir();
221
+ const filePath = join(dir, "notes.txt");
222
+ writeFileSync(filePath, "hello world\nsecond line\n");
223
+
224
+ const result = await hostFileReadTool.execute(
225
+ { path: filePath },
226
+ makeContext(),
227
+ );
228
+
229
+ expect(result.isError).toBe(false);
230
+ expect(result.content).toContain("1 hello world");
231
+ expect(result.content).toContain("2 second line");
232
+ expect((result as any).contentBlocks).toBeUndefined();
233
+ });
234
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Unit tests for the identity intro cache (identity-intro-cache.ts).
3
+ *
4
+ * Validates TTL-based expiration, content-hash-based invalidation when
5
+ * workspace identity files change, and round-trip get/set behavior.
6
+ */
7
+
8
+ import { afterEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Mocks — must be defined before importing the module under test
12
+ // ---------------------------------------------------------------------------
13
+
14
+ // Suppress logger output
15
+ mock.module("../util/logger.js", () => ({
16
+ getLogger: () =>
17
+ new Proxy({} as Record<string, unknown>, {
18
+ get: () => () => {},
19
+ }),
20
+ }));
21
+
22
+ // In-memory checkpoint store
23
+ const checkpointStore = new Map<string, string>();
24
+
25
+ mock.module("../memory/checkpoints.js", () => ({
26
+ getMemoryCheckpoint: (key: string) => checkpointStore.get(key) ?? null,
27
+ setMemoryCheckpoint: (key: string, value: string) => {
28
+ checkpointStore.set(key, value);
29
+ },
30
+ }));
31
+
32
+ // Simulated workspace file contents
33
+ const workspaceFiles: Record<string, string> = {};
34
+
35
+ mock.module("../util/platform.js", () => ({
36
+ getWorkspacePromptPath: (name: string) => `/mock/workspace/${name}`,
37
+ }));
38
+
39
+ mock.module("node:fs", () => ({
40
+ existsSync: (path: string) => {
41
+ const name = path.split("/").pop() ?? "";
42
+ return name in workspaceFiles;
43
+ },
44
+ readFileSync: (path: string, _encoding: string) => {
45
+ const name = path.split("/").pop() ?? "";
46
+ if (name in workspaceFiles) return workspaceFiles[name];
47
+ throw new Error(`ENOENT: ${path}`);
48
+ },
49
+ }));
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Imports (after mocks)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ import {
56
+ computeIdentityContentHash,
57
+ getCachedIntro,
58
+ setCachedIntro,
59
+ } from "../runtime/routes/identity-intro-cache.js";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ afterEach(() => {
66
+ checkpointStore.clear();
67
+ for (const key of Object.keys(workspaceFiles)) {
68
+ delete workspaceFiles[key];
69
+ }
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tests
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("identity intro cache", () => {
77
+ test("returns null when cache is empty", () => {
78
+ expect(getCachedIntro()).toBeNull();
79
+ });
80
+
81
+ test("round-trip: set then get returns cached text", () => {
82
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
83
+ workspaceFiles["SOUL.md"] = "Be playful.";
84
+ workspaceFiles["USER.md"] = "The user likes coffee.";
85
+
86
+ setCachedIntro("Hey, I'm Atlas.");
87
+ const cached = getCachedIntro();
88
+ expect(cached).not.toBeNull();
89
+ expect(cached!.text).toBe("Hey, I'm Atlas.");
90
+ });
91
+
92
+ test("returns null when cache is expired (TTL exceeded)", () => {
93
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
94
+
95
+ setCachedIntro("Hello!");
96
+
97
+ // Manually set the timestamp to 5 hours ago
98
+ const fiveHoursAgo = String(Date.now() - 5 * 60 * 60 * 1000);
99
+ checkpointStore.set("identity:intro:cached_at", fiveHoursAgo);
100
+
101
+ expect(getCachedIntro()).toBeNull();
102
+ });
103
+
104
+ test("returns cached text when within TTL (3 hours ago)", () => {
105
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
106
+
107
+ setCachedIntro("Hello!");
108
+
109
+ // Set timestamp to 3 hours ago (within 4-hour TTL)
110
+ const threeHoursAgo = String(Date.now() - 3 * 60 * 60 * 1000);
111
+ checkpointStore.set("identity:intro:cached_at", threeHoursAgo);
112
+
113
+ const cached = getCachedIntro();
114
+ expect(cached).not.toBeNull();
115
+ expect(cached!.text).toBe("Hello!");
116
+ });
117
+
118
+ test("busts cache when IDENTITY.md changes", () => {
119
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
120
+ setCachedIntro("I'm Atlas!");
121
+
122
+ // Change IDENTITY.md
123
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Nova";
124
+
125
+ expect(getCachedIntro()).toBeNull();
126
+ });
127
+
128
+ test("busts cache when SOUL.md changes", () => {
129
+ workspaceFiles["SOUL.md"] = "Be playful.";
130
+ setCachedIntro("Hey there!");
131
+
132
+ // Change SOUL.md
133
+ workspaceFiles["SOUL.md"] = "Be serious and formal.";
134
+
135
+ expect(getCachedIntro()).toBeNull();
136
+ });
137
+
138
+ test("busts cache when USER.md changes", () => {
139
+ workspaceFiles["USER.md"] = "Likes coffee.";
140
+ setCachedIntro("Good morning!");
141
+
142
+ // Change USER.md
143
+ workspaceFiles["USER.md"] = "Likes tea.";
144
+
145
+ expect(getCachedIntro()).toBeNull();
146
+ });
147
+
148
+ test("cache survives when files are unchanged", () => {
149
+ workspaceFiles["IDENTITY.md"] = "- **Name:** Atlas";
150
+ workspaceFiles["SOUL.md"] = "Be chill.";
151
+ workspaceFiles["USER.md"] = "Likes sunsets.";
152
+
153
+ setCachedIntro("Atlas here.");
154
+
155
+ // Read twice — both should return the cached value
156
+ expect(getCachedIntro()?.text).toBe("Atlas here.");
157
+ expect(getCachedIntro()?.text).toBe("Atlas here.");
158
+ });
159
+
160
+ test("computeIdentityContentHash is deterministic", () => {
161
+ workspaceFiles["IDENTITY.md"] = "test";
162
+ workspaceFiles["SOUL.md"] = "test2";
163
+ workspaceFiles["USER.md"] = "test3";
164
+
165
+ const hash1 = computeIdentityContentHash();
166
+ const hash2 = computeIdentityContentHash();
167
+ expect(hash1).toBe(hash2);
168
+ expect(hash1).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex
169
+ });
170
+
171
+ test("computeIdentityContentHash changes when file content changes", () => {
172
+ workspaceFiles["IDENTITY.md"] = "v1";
173
+ const hash1 = computeIdentityContentHash();
174
+
175
+ workspaceFiles["IDENTITY.md"] = "v2";
176
+ const hash2 = computeIdentityContentHash();
177
+
178
+ expect(hash1).not.toBe(hash2);
179
+ });
180
+
181
+ test("handles missing workspace files gracefully", () => {
182
+ // No files exist — should still work (empty content hashed)
183
+ setCachedIntro("Hello!");
184
+ const cached = getCachedIntro();
185
+ expect(cached).not.toBeNull();
186
+ expect(cached!.text).toBe("Hello!");
187
+ });
188
+
189
+ test("returns null when text checkpoint is missing", () => {
190
+ checkpointStore.set("identity:intro:content_hash", "abc");
191
+ checkpointStore.set("identity:intro:cached_at", String(Date.now()));
192
+ // Missing text — should return null
193
+ expect(getCachedIntro()).toBeNull();
194
+ });
195
+
196
+ test("returns null when hash checkpoint is missing", () => {
197
+ checkpointStore.set("identity:intro:text", "Hello");
198
+ checkpointStore.set("identity:intro:cached_at", String(Date.now()));
199
+ // Missing hash — should return null
200
+ expect(getCachedIntro()).toBeNull();
201
+ });
202
+
203
+ test("returns null when timestamp checkpoint is missing", () => {
204
+ checkpointStore.set("identity:intro:text", "Hello");
205
+ checkpointStore.set("identity:intro:content_hash", "abc");
206
+ // Missing timestamp — should return null
207
+ expect(getCachedIntro()).toBeNull();
208
+ });
209
+ });