@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.
- package/package.json +1 -1
- package/src/__tests__/assistant-feature-flags-integration.test.ts +7 -9
- package/src/__tests__/conversation-runtime-assembly.test.ts +28 -21
- package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
- package/src/__tests__/encrypted-store.test.ts +24 -12
- package/src/__tests__/file-read-tool.test.ts +40 -0
- package/src/__tests__/filesystem-tools.test.ts +4 -2
- package/src/__tests__/history-repair.test.ts +71 -0
- package/src/__tests__/host-file-read-tool.test.ts +87 -0
- package/src/__tests__/identity-intro-cache.test.ts +209 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +3 -3
- package/src/__tests__/skill-feature-flags-integration.test.ts +18 -17
- package/src/__tests__/skill-feature-flags.test.ts +13 -13
- package/src/__tests__/skill-load-feature-flag.test.ts +4 -4
- package/src/__tests__/skill-memory.test.ts +14 -12
- package/src/__tests__/system-prompt.test.ts +8 -0
- package/src/config/feature-flag-registry.json +9 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +2 -39
- package/src/daemon/conversation-runtime-assembly.ts +4 -3
- package/src/daemon/history-repair.ts +28 -8
- package/src/daemon/trace-emitter.ts +3 -2
- package/src/memory/search/staleness.ts +4 -1
- package/src/notifications/decision-engine.ts +43 -2
- package/src/notifications/emit-signal.ts +1 -0
- package/src/permissions/checker.ts +0 -20
- package/src/prompts/system-prompt.ts +2 -0
- package/src/prompts/templates/BOOTSTRAP.md +10 -4
- package/src/prompts/templates/IDENTITY.md +1 -2
- package/src/providers/anthropic/client.ts +5 -17
- package/src/runtime/access-request-helper.ts +15 -1
- package/src/runtime/guardian-vellum-migration.ts +1 -3
- package/src/runtime/routes/btw-routes.ts +84 -0
- package/src/runtime/routes/identity-intro-cache.ts +105 -0
- package/src/runtime/routes/identity-routes.ts +51 -0
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/security/encrypted-store.ts +1 -2
- package/src/skills/skill-memory.ts +5 -3
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +2 -0
- package/src/tools/filesystem/read.ts +14 -3
- package/src/tools/host-filesystem/read.ts +17 -1
- package/src/tools/shared/filesystem/format-diff.ts +4 -16
- package/src/util/pricing.ts +4 -0
package/package.json
CHANGED
|
@@ -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("
|
|
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
|
-
//
|
|
267
|
-
expect(result).
|
|
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:
|
|
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("
|
|
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(
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1058
|
+
const block = buildTurnContextBlock(
|
|
1059
|
+
{
|
|
1060
|
+
turnContext: {
|
|
1061
|
+
userMessageChannel: "telegram",
|
|
1062
|
+
assistantMessageChannel: "telegram",
|
|
1063
|
+
},
|
|
1064
|
+
conversationOriginChannel: "telegram",
|
|
1062
1065
|
},
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1074
|
+
const block = buildTurnContextBlock(
|
|
1075
|
+
{
|
|
1076
|
+
turnContext: {
|
|
1077
|
+
userMessageChannel: "vellum",
|
|
1078
|
+
assistantMessageChannel: "vellum",
|
|
1079
|
+
},
|
|
1080
|
+
conversationOriginChannel: null,
|
|
1077
1081
|
},
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
+
const block = buildTurnContextBlock(
|
|
1089
|
+
{
|
|
1090
|
+
turnContext: {
|
|
1091
|
+
userMessageChannel: "telegram",
|
|
1092
|
+
assistantMessageChannel: "vellum",
|
|
1093
|
+
},
|
|
1094
|
+
conversationOriginChannel: "vellum",
|
|
1088
1095
|
},
|
|
1089
|
-
|
|
1090
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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 {
|
|
76
|
-
|
|
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 {
|
|
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("
|
|
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
|
+
});
|