@vellumai/assistant 0.4.49 → 0.4.50
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/ARCHITECTURE.md +24 -33
- package/README.md +3 -3
- package/docs/architecture/memory.md +180 -119
- package/package.json +2 -2
- package/src/__tests__/agent-loop.test.ts +3 -1
- package/src/__tests__/anthropic-provider.test.ts +114 -23
- package/src/__tests__/approval-cascade.test.ts +1 -15
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
- package/src/__tests__/canonical-guardian-store.test.ts +95 -0
- package/src/__tests__/checker.test.ts +13 -0
- package/src/__tests__/config-schema.test.ts +1 -68
- package/src/__tests__/context-memory-e2e.test.ts +11 -100
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
- package/src/__tests__/credential-security-e2e.test.ts +1 -0
- package/src/__tests__/credential-vault-unit.test.ts +4 -0
- package/src/__tests__/credential-vault.test.ts +13 -1
- package/src/__tests__/cu-unified-flow.test.ts +532 -0
- package/src/__tests__/date-context.test.ts +93 -77
- package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
- package/src/__tests__/history-repair.test.ts +245 -0
- package/src/__tests__/host-cu-proxy.test.ts +165 -3
- package/src/__tests__/http-user-message-parity.test.ts +1 -0
- package/src/__tests__/invite-redemption-service.test.ts +65 -1
- package/src/__tests__/keychain-broker-client.test.ts +4 -4
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
- package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
- package/src/__tests__/memory-recall-quality.test.ts +244 -407
- package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
- package/src/__tests__/memory-regressions.test.ts +477 -2841
- package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
- package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
- package/src/__tests__/mime-builder.test.ts +28 -0
- package/src/__tests__/native-web-search.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +572 -5
- package/src/__tests__/oauth-store.test.ts +120 -6
- package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
- package/src/__tests__/registry.test.ts +0 -1
- package/src/__tests__/relay-server.test.ts +46 -1
- package/src/__tests__/schedule-tools.test.ts +32 -0
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +1 -0
- package/src/__tests__/secure-keys.test.ts +7 -2
- package/src/__tests__/send-endpoint-busy.test.ts +3 -0
- package/src/__tests__/session-abort-tool-results.test.ts +1 -14
- package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
- package/src/__tests__/session-agent-loop.test.ts +19 -15
- package/src/__tests__/session-confirmation-signals.test.ts +1 -15
- package/src/__tests__/session-error.test.ts +124 -2
- package/src/__tests__/session-history-web-search.test.ts +918 -0
- package/src/__tests__/session-pre-run-repair.test.ts +1 -14
- package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
- package/src/__tests__/session-queue.test.ts +37 -27
- package/src/__tests__/session-runtime-assembly.test.ts +54 -0
- package/src/__tests__/session-slash-known.test.ts +1 -15
- package/src/__tests__/session-slash-queue.test.ts +1 -15
- package/src/__tests__/session-slash-unknown.test.ts +1 -15
- package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
- package/src/__tests__/session-workspace-injection.test.ts +3 -37
- package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
- package/src/__tests__/skills-install-extract.test.ts +93 -0
- package/src/__tests__/skillssh-registry.test.ts +451 -0
- package/src/__tests__/trust-store.test.ts +15 -0
- package/src/__tests__/voice-invite-redemption.test.ts +32 -1
- package/src/agent/ax-tree-compaction.test.ts +51 -0
- package/src/agent/loop.ts +39 -12
- package/src/approvals/AGENTS.md +1 -1
- package/src/approvals/guardian-request-resolvers.ts +14 -2
- package/src/bundler/compiler-tools.ts +66 -2
- package/src/calls/call-domain.ts +132 -0
- package/src/calls/call-store.ts +6 -0
- package/src/calls/relay-server.ts +43 -5
- package/src/calls/relay-setup-router.ts +17 -1
- package/src/calls/twilio-config.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli/commands/doctor.ts +4 -3
- package/src/cli/commands/mcp.ts +46 -59
- package/src/cli/commands/memory.ts +16 -165
- package/src/cli/commands/oauth/apps.ts +31 -2
- package/src/cli/commands/oauth/connections.ts +431 -97
- package/src/cli/commands/oauth/providers.ts +15 -1
- package/src/cli/commands/sessions.ts +5 -2
- package/src/cli/commands/skills.ts +173 -1
- package/src/cli/http-client.ts +0 -20
- package/src/cli/main-screen.tsx +2 -2
- package/src/cli/program.ts +5 -6
- package/src/cli.ts +4 -10
- package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
- package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
- package/src/config/bundled-tool-registry.ts +2 -5
- package/src/config/schema.ts +1 -12
- package/src/config/schemas/memory-lifecycle.ts +0 -9
- package/src/config/schemas/memory-processing.ts +0 -180
- package/src/config/schemas/memory-retrieval.ts +32 -104
- package/src/config/schemas/memory.ts +0 -10
- package/src/config/types.ts +0 -4
- package/src/context/window-manager.ts +4 -1
- package/src/daemon/config-watcher.ts +61 -3
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/date-context.ts +114 -31
- package/src/daemon/handlers/sessions.ts +18 -13
- package/src/daemon/handlers/skills.ts +20 -1
- package/src/daemon/history-repair.ts +72 -8
- package/src/daemon/host-cu-proxy.ts +55 -26
- package/src/daemon/lifecycle.ts +31 -3
- package/src/daemon/mcp-reload-service.ts +2 -2
- package/src/daemon/message-types/computer-use.ts +1 -12
- package/src/daemon/message-types/memory.ts +4 -16
- package/src/daemon/message-types/messages.ts +1 -0
- package/src/daemon/message-types/sessions.ts +4 -0
- package/src/daemon/server.ts +12 -1
- package/src/daemon/session-agent-loop-handlers.ts +38 -0
- package/src/daemon/session-agent-loop.ts +334 -48
- package/src/daemon/session-error.ts +89 -6
- package/src/daemon/session-history.ts +17 -7
- package/src/daemon/session-media-retry.ts +6 -2
- package/src/daemon/session-memory.ts +69 -149
- package/src/daemon/session-process.ts +10 -1
- package/src/daemon/session-runtime-assembly.ts +49 -19
- package/src/daemon/session-surfaces.ts +4 -1
- package/src/daemon/session-tool-setup.ts +7 -1
- package/src/daemon/session.ts +12 -2
- package/src/instrument.ts +61 -1
- package/src/memory/admin.ts +2 -191
- package/src/memory/canonical-guardian-store.ts +38 -2
- package/src/memory/conversation-crud.ts +0 -33
- package/src/memory/conversation-queries.ts +22 -3
- package/src/memory/db-init.ts +28 -0
- package/src/memory/embedding-backend.ts +84 -8
- package/src/memory/embedding-types.ts +9 -1
- package/src/memory/indexer.ts +7 -46
- package/src/memory/items-extractor.ts +274 -76
- package/src/memory/job-handlers/backfill.ts +2 -127
- package/src/memory/job-handlers/cleanup.ts +2 -16
- package/src/memory/job-handlers/extraction.ts +2 -138
- package/src/memory/job-handlers/index-maintenance.ts +1 -6
- package/src/memory/job-handlers/summarization.ts +3 -148
- package/src/memory/job-utils.ts +21 -59
- package/src/memory/jobs-store.ts +1 -159
- package/src/memory/jobs-worker.ts +9 -52
- package/src/memory/migrations/104-core-indexes.ts +3 -3
- package/src/memory/migrations/149-oauth-tables.ts +2 -0
- package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
- package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
- package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
- package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
- package/src/memory/migrations/154-drop-fts.ts +20 -0
- package/src/memory/migrations/155-drop-conflicts.ts +7 -0
- package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
- package/src/memory/migrations/index.ts +7 -0
- package/src/memory/qdrant-client.ts +148 -51
- package/src/memory/raw-query.ts +1 -1
- package/src/memory/retriever.test.ts +294 -273
- package/src/memory/retriever.ts +421 -645
- package/src/memory/schema/calls.ts +2 -0
- package/src/memory/schema/memory-core.ts +3 -48
- package/src/memory/schema/oauth.ts +2 -0
- package/src/memory/search/formatting.ts +263 -176
- package/src/memory/search/lexical.ts +1 -254
- package/src/memory/search/ranking.ts +0 -455
- package/src/memory/search/semantic.ts +100 -14
- package/src/memory/search/staleness.ts +47 -0
- package/src/memory/search/tier-classifier.ts +21 -0
- package/src/memory/search/types.ts +15 -77
- package/src/memory/task-memory-cleanup.ts +4 -6
- package/src/messaging/providers/gmail/mime-builder.ts +17 -7
- package/src/oauth/byo-connection.test.ts +8 -1
- package/src/oauth/oauth-store.ts +113 -27
- package/src/oauth/seed-providers.ts +6 -0
- package/src/oauth/token-persistence.ts +11 -3
- package/src/permissions/defaults.ts +1 -0
- package/src/permissions/trust-store.ts +23 -1
- package/src/playbooks/playbook-compiler.ts +1 -1
- package/src/prompts/system-prompt.ts +18 -2
- package/src/providers/anthropic/client.ts +56 -126
- package/src/providers/types.ts +7 -1
- package/src/runtime/AGENTS.md +9 -0
- package/src/runtime/auth/route-policy.ts +6 -3
- package/src/runtime/guardian-reply-router.ts +24 -22
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/invite-redemption-service.ts +19 -1
- package/src/runtime/invite-service.ts +25 -0
- package/src/runtime/pending-interactions.ts +2 -2
- package/src/runtime/routes/brain-graph-routes.ts +10 -90
- package/src/runtime/routes/conversation-routes.ts +9 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
- package/src/runtime/routes/memory-item-routes.test.ts +754 -0
- package/src/runtime/routes/memory-item-routes.ts +503 -0
- package/src/runtime/routes/session-management-routes.ts +3 -3
- package/src/runtime/routes/settings-routes.ts +2 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -0
- package/src/runtime/routes/workspace-routes.ts +2 -1
- package/src/security/keychain-broker-client.ts +17 -4
- package/src/security/secure-keys.ts +25 -3
- package/src/security/token-manager.ts +36 -36
- package/src/skills/catalog-install.ts +74 -18
- package/src/skills/skillssh-registry.ts +503 -0
- package/src/tools/assets/search.ts +5 -1
- package/src/tools/computer-use/definitions.ts +0 -10
- package/src/tools/computer-use/registry.ts +1 -1
- package/src/tools/credentials/vault.ts +1 -3
- package/src/tools/memory/definitions.ts +4 -13
- package/src/tools/memory/handlers.test.ts +83 -103
- package/src/tools/memory/handlers.ts +50 -85
- package/src/tools/schedule/create.ts +8 -1
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/skills/load.ts +25 -2
- package/src/__tests__/clarification-resolver.test.ts +0 -193
- package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
- package/src/__tests__/conflict-policy.test.ts +0 -269
- package/src/__tests__/conflict-store.test.ts +0 -372
- package/src/__tests__/contradiction-checker.test.ts +0 -361
- package/src/__tests__/entity-extractor.test.ts +0 -211
- package/src/__tests__/entity-search.test.ts +0 -1117
- package/src/__tests__/profile-compiler.test.ts +0 -392
- package/src/__tests__/session-conflict-gate.test.ts +0 -1228
- package/src/__tests__/session-profile-injection.test.ts +0 -557
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
- package/src/daemon/session-conflict-gate.ts +0 -167
- package/src/daemon/session-dynamic-profile.ts +0 -77
- package/src/memory/clarification-resolver.ts +0 -417
- package/src/memory/conflict-intent.ts +0 -205
- package/src/memory/conflict-policy.ts +0 -127
- package/src/memory/conflict-store.ts +0 -410
- package/src/memory/contradiction-checker.ts +0 -508
- package/src/memory/entity-extractor.ts +0 -535
- package/src/memory/format-recall.ts +0 -47
- package/src/memory/fts-reconciler.ts +0 -165
- package/src/memory/job-handlers/conflict.ts +0 -200
- package/src/memory/profile-compiler.ts +0 -195
- package/src/memory/recall-cache.ts +0 -117
- package/src/memory/search/entity.ts +0 -535
- package/src/memory/search/query-expansion.test.ts +0 -70
- package/src/memory/search/query-expansion.ts +0 -118
- package/src/runtime/routes/mcp-routes.ts +0 -20
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import { extractTarToDir } from "../skills/catalog-install.js";
|
|
7
|
+
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
function makeTarEntry(name: string, content: string): Buffer {
|
|
11
|
+
const header = Buffer.alloc(512, 0);
|
|
12
|
+
const nameBuffer = Buffer.from(name, "utf-8");
|
|
13
|
+
nameBuffer.copy(header, 0, 0, Math.min(nameBuffer.length, 100));
|
|
14
|
+
|
|
15
|
+
const mode = Buffer.from("0000644\0", "ascii");
|
|
16
|
+
mode.copy(header, 100);
|
|
17
|
+
Buffer.from("0000000\0", "ascii").copy(header, 108); // uid
|
|
18
|
+
Buffer.from("0000000\0", "ascii").copy(header, 116); // gid
|
|
19
|
+
|
|
20
|
+
const sizeOct = content.length.toString(8).padStart(11, "0") + "\0";
|
|
21
|
+
Buffer.from(sizeOct, "ascii").copy(header, 124);
|
|
22
|
+
|
|
23
|
+
Buffer.from("00000000000\0", "ascii").copy(header, 136); // mtime
|
|
24
|
+
Buffer.from(" ", "ascii").copy(header, 148); // checksum placeholder
|
|
25
|
+
header[156] = "0".charCodeAt(0);
|
|
26
|
+
Buffer.from("ustar\0", "ascii").copy(header, 257);
|
|
27
|
+
Buffer.from("00", "ascii").copy(header, 263);
|
|
28
|
+
|
|
29
|
+
let sum = 0;
|
|
30
|
+
for (let i = 0; i < 512; i += 1) sum += header[i] ?? 0;
|
|
31
|
+
const checksum = sum.toString(8).padStart(6, "0");
|
|
32
|
+
Buffer.from(`${checksum}\0 `, "ascii").copy(header, 148);
|
|
33
|
+
|
|
34
|
+
const data = Buffer.from(content, "utf-8");
|
|
35
|
+
const paddedSize = Math.ceil(data.length / 512) * 512;
|
|
36
|
+
const padded = Buffer.alloc(paddedSize, 0);
|
|
37
|
+
data.copy(padded);
|
|
38
|
+
|
|
39
|
+
return Buffer.concat([header, padded]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTar(entries: Array<{ name: string; content: string }>): Buffer {
|
|
43
|
+
const body = entries.map((entry) => makeTarEntry(entry.name, entry.content));
|
|
44
|
+
return Buffer.concat([...body, Buffer.alloc(1024, 0)]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
tempDir = join(
|
|
49
|
+
tmpdir(),
|
|
50
|
+
`skills-extract-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
51
|
+
);
|
|
52
|
+
mkdirSync(tempDir, { recursive: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("extractTarToDir", () => {
|
|
60
|
+
test("extracts valid files and detects SKILL.md", () => {
|
|
61
|
+
const tar = makeTar([
|
|
62
|
+
{ name: "SKILL.md", content: "# demo\n" },
|
|
63
|
+
{ name: "scripts/run.sh", content: "echo ok\n" },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const foundSkillMd = extractTarToDir(tar, tempDir);
|
|
67
|
+
|
|
68
|
+
expect(foundSkillMd).toBe(true);
|
|
69
|
+
expect(readFileSync(join(tempDir, "SKILL.md"), "utf-8")).toBe("# demo\n");
|
|
70
|
+
expect(readFileSync(join(tempDir, "scripts", "run.sh"), "utf-8")).toBe(
|
|
71
|
+
"echo ok\n",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rejects traversal and absolute archive paths", () => {
|
|
76
|
+
const tar = makeTar([
|
|
77
|
+
{ name: "SKILL.md", content: "# demo\n" },
|
|
78
|
+
{ name: "../../escape.txt", content: "nope\n" },
|
|
79
|
+
{ name: "..\\..\\win-escape.txt", content: "nope\n" },
|
|
80
|
+
{ name: "/absolute.txt", content: "nope\n" },
|
|
81
|
+
{ name: "C:/windows.txt", content: "nope\n" },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const foundSkillMd = extractTarToDir(tar, tempDir);
|
|
85
|
+
|
|
86
|
+
expect(foundSkillMd).toBe(true);
|
|
87
|
+
expect(existsSync(join(tempDir, "escape.txt"))).toBe(false);
|
|
88
|
+
expect(existsSync(join(tempDir, "win-escape.txt"))).toBe(false);
|
|
89
|
+
expect(existsSync(join(tempDir, "absolute.txt"))).toBe(false);
|
|
90
|
+
expect(existsSync(join(tempDir, "windows.txt"))).toBe(false);
|
|
91
|
+
expect(readFileSync(join(tempDir, "SKILL.md"), "utf-8")).toBe("# demo\n");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuditResponse,
|
|
5
|
+
SkillAuditData,
|
|
6
|
+
SkillsShSearchResult,
|
|
7
|
+
} from "../skills/skillssh-registry.js";
|
|
8
|
+
import {
|
|
9
|
+
fetchSkillAudits,
|
|
10
|
+
fetchSkillFromGitHub,
|
|
11
|
+
formatAuditBadges,
|
|
12
|
+
providerDisplayName,
|
|
13
|
+
resolveSkillSource,
|
|
14
|
+
riskToDisplay,
|
|
15
|
+
searchSkillsRegistry,
|
|
16
|
+
validateSkillSlug,
|
|
17
|
+
} from "../skills/skillssh-registry.js";
|
|
18
|
+
|
|
19
|
+
// ─── Fetch mock helpers ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
let mockFetchImpl: (url: string | URL | Request) => Promise<Response>;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockFetchImpl = () =>
|
|
27
|
+
Promise.resolve(new Response("not mocked", { status: 500 }));
|
|
28
|
+
globalThis.fetch = mock((input: string | URL | Request) =>
|
|
29
|
+
mockFetchImpl(typeof input === "string" ? input : input.toString()),
|
|
30
|
+
) as unknown as typeof fetch;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ─── searchSkillsRegistry ────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("searchSkillsRegistry", () => {
|
|
40
|
+
test("sends correct query parameters and returns results", async () => {
|
|
41
|
+
const mockResults: SkillsShSearchResult[] = [
|
|
42
|
+
{
|
|
43
|
+
id: "vercel-labs/agent-skills/vercel-react-best-practices",
|
|
44
|
+
skillId: "vercel-react-best-practices",
|
|
45
|
+
name: "Vercel React Best Practices",
|
|
46
|
+
installs: 1200,
|
|
47
|
+
source: "vercel-labs/agent-skills",
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
52
|
+
const urlStr = url.toString();
|
|
53
|
+
expect(urlStr).toContain("skills.sh/api/search");
|
|
54
|
+
expect(urlStr).toContain("q=react");
|
|
55
|
+
expect(urlStr).toContain("limit=5");
|
|
56
|
+
return Promise.resolve(
|
|
57
|
+
new Response(JSON.stringify({ skills: mockResults }), {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const results = await searchSkillsRegistry("react", 5);
|
|
65
|
+
expect(results).toEqual(mockResults);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("omits limit parameter when not provided", async () => {
|
|
69
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
70
|
+
const urlStr = url.toString();
|
|
71
|
+
expect(urlStr).not.toContain("limit=");
|
|
72
|
+
return Promise.resolve(
|
|
73
|
+
new Response(JSON.stringify({ skills: [] }), {
|
|
74
|
+
status: 200,
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const results = await searchSkillsRegistry("test");
|
|
81
|
+
expect(results).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws on non-OK response", async () => {
|
|
85
|
+
mockFetchImpl = () =>
|
|
86
|
+
Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
87
|
+
|
|
88
|
+
await expect(searchSkillsRegistry("bad-query")).rejects.toThrow(
|
|
89
|
+
"skills.sh search failed: HTTP 404",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ─── fetchSkillAudits ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe("fetchSkillAudits", () => {
|
|
97
|
+
test("sends correct parameters and returns audit data", async () => {
|
|
98
|
+
const mockAudits: AuditResponse = {
|
|
99
|
+
"vercel-react-best-practices": {
|
|
100
|
+
ath: {
|
|
101
|
+
risk: "safe",
|
|
102
|
+
alerts: 0,
|
|
103
|
+
score: 100,
|
|
104
|
+
analyzedAt: "2025-01-15T00:00:00Z",
|
|
105
|
+
},
|
|
106
|
+
socket: {
|
|
107
|
+
risk: "low",
|
|
108
|
+
alerts: 1,
|
|
109
|
+
score: 95,
|
|
110
|
+
analyzedAt: "2025-01-15T00:00:00Z",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
116
|
+
const urlStr = url.toString();
|
|
117
|
+
expect(urlStr).toContain("add-skill.vercel.sh/audit");
|
|
118
|
+
expect(urlStr).toContain("source=vercel-labs%2Fagent-skills");
|
|
119
|
+
expect(urlStr).toContain(
|
|
120
|
+
"skills=vercel-react-best-practices%2Canother-skill",
|
|
121
|
+
);
|
|
122
|
+
return Promise.resolve(
|
|
123
|
+
new Response(JSON.stringify(mockAudits), {
|
|
124
|
+
status: 200,
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const audits = await fetchSkillAudits("vercel-labs/agent-skills", [
|
|
131
|
+
"vercel-react-best-practices",
|
|
132
|
+
"another-skill",
|
|
133
|
+
]);
|
|
134
|
+
expect(audits).toEqual(mockAudits);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("returns empty object for empty slugs list", async () => {
|
|
138
|
+
const audits = await fetchSkillAudits("some/source", []);
|
|
139
|
+
expect(audits).toEqual({});
|
|
140
|
+
// fetch should not have been called
|
|
141
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("throws on non-OK response", async () => {
|
|
145
|
+
mockFetchImpl = () =>
|
|
146
|
+
Promise.resolve(new Response("Internal Server Error", { status: 500 }));
|
|
147
|
+
|
|
148
|
+
await expect(fetchSkillAudits("some/source", ["slug"])).rejects.toThrow(
|
|
149
|
+
"Audit fetch failed: HTTP 500",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ─── Display helpers ─────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe("riskToDisplay", () => {
|
|
157
|
+
test("maps risk levels correctly", () => {
|
|
158
|
+
expect(riskToDisplay("safe")).toBe("PASS");
|
|
159
|
+
expect(riskToDisplay("low")).toBe("PASS");
|
|
160
|
+
expect(riskToDisplay("medium")).toBe("WARN");
|
|
161
|
+
expect(riskToDisplay("high")).toBe("FAIL");
|
|
162
|
+
expect(riskToDisplay("critical")).toBe("FAIL");
|
|
163
|
+
expect(riskToDisplay("unknown")).toBe("?");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("providerDisplayName", () => {
|
|
168
|
+
test("maps known providers", () => {
|
|
169
|
+
expect(providerDisplayName("ath")).toBe("ATH");
|
|
170
|
+
expect(providerDisplayName("socket")).toBe("Socket");
|
|
171
|
+
expect(providerDisplayName("snyk")).toBe("Snyk");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns raw name for unknown providers", () => {
|
|
175
|
+
expect(providerDisplayName("custom-auditor")).toBe("custom-auditor");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("formatAuditBadges", () => {
|
|
180
|
+
test("formats multiple providers as badges", () => {
|
|
181
|
+
const auditData: SkillAuditData = {
|
|
182
|
+
ath: { risk: "safe", analyzedAt: "2025-01-15T00:00:00Z" },
|
|
183
|
+
socket: { risk: "safe", analyzedAt: "2025-01-15T00:00:00Z" },
|
|
184
|
+
snyk: { risk: "medium", analyzedAt: "2025-01-15T00:00:00Z" },
|
|
185
|
+
};
|
|
186
|
+
expect(formatAuditBadges(auditData)).toBe(
|
|
187
|
+
"Security: [ATH:PASS] [Socket:PASS] [Snyk:WARN]",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("returns fallback message when no providers present", () => {
|
|
192
|
+
expect(formatAuditBadges({})).toBe("Security: no audit data");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("handles single provider", () => {
|
|
196
|
+
const auditData: SkillAuditData = {
|
|
197
|
+
ath: { risk: "critical", analyzedAt: "2025-01-15T00:00:00Z" },
|
|
198
|
+
};
|
|
199
|
+
expect(formatAuditBadges(auditData)).toBe("Security: [ATH:FAIL]");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ─── resolveSkillSource ─────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("resolveSkillSource", () => {
|
|
206
|
+
test("parses owner/repo@skill-name format", () => {
|
|
207
|
+
const result = resolveSkillSource("vercel-labs/skills@find-skills");
|
|
208
|
+
expect(result).toEqual({
|
|
209
|
+
owner: "vercel-labs",
|
|
210
|
+
repo: "skills",
|
|
211
|
+
skillSlug: "find-skills",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("parses owner/repo/skill-name format", () => {
|
|
216
|
+
const result = resolveSkillSource("vercel-labs/skills/find-skills");
|
|
217
|
+
expect(result).toEqual({
|
|
218
|
+
owner: "vercel-labs",
|
|
219
|
+
repo: "skills",
|
|
220
|
+
skillSlug: "find-skills",
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("parses full GitHub URL with main branch", () => {
|
|
225
|
+
const result = resolveSkillSource(
|
|
226
|
+
"https://github.com/vercel-labs/skills/tree/main/skills/find-skills",
|
|
227
|
+
);
|
|
228
|
+
expect(result).toEqual({
|
|
229
|
+
owner: "vercel-labs",
|
|
230
|
+
repo: "skills",
|
|
231
|
+
skillSlug: "find-skills",
|
|
232
|
+
ref: "main",
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("parses full GitHub URL with non-main branch", () => {
|
|
237
|
+
const result = resolveSkillSource(
|
|
238
|
+
"https://github.com/some-org/repo/tree/develop/skills/my-skill",
|
|
239
|
+
);
|
|
240
|
+
expect(result).toEqual({
|
|
241
|
+
owner: "some-org",
|
|
242
|
+
repo: "repo",
|
|
243
|
+
skillSlug: "my-skill",
|
|
244
|
+
ref: "develop",
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("parses GitHub URL with trailing slash", () => {
|
|
249
|
+
const result = resolveSkillSource(
|
|
250
|
+
"https://github.com/owner/repo/tree/main/skills/skill-name/",
|
|
251
|
+
);
|
|
252
|
+
expect(result).toEqual({
|
|
253
|
+
owner: "owner",
|
|
254
|
+
repo: "repo",
|
|
255
|
+
skillSlug: "skill-name",
|
|
256
|
+
ref: "main",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("throws on bare skill name (no owner/repo)", () => {
|
|
261
|
+
expect(() => resolveSkillSource("find-skills")).toThrow(
|
|
262
|
+
'Invalid skill source "find-skills"',
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("throws on empty string", () => {
|
|
267
|
+
expect(() => resolveSkillSource("")).toThrow('Invalid skill source ""');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("throws on owner-only format", () => {
|
|
271
|
+
expect(() => resolveSkillSource("vercel-labs")).toThrow(
|
|
272
|
+
'Invalid skill source "vercel-labs"',
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("throws on owner/repo without skill", () => {
|
|
277
|
+
expect(() => resolveSkillSource("vercel-labs/skills")).toThrow(
|
|
278
|
+
'Invalid skill source "vercel-labs/skills"',
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("rejects path traversal in @ format slug", () => {
|
|
283
|
+
expect(() => resolveSkillSource("owner/repo@../../malicious")).toThrow(
|
|
284
|
+
'Invalid skill source "owner/repo@../../malicious"',
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("rejects uppercase slug in @ format", () => {
|
|
289
|
+
expect(() => resolveSkillSource("owner/repo@BadSlug")).toThrow(
|
|
290
|
+
'Invalid skill source "owner/repo@BadSlug"',
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ─── validateSkillSlug ──────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
describe("validateSkillSlug", () => {
|
|
298
|
+
test("accepts valid slugs", () => {
|
|
299
|
+
expect(() => validateSkillSlug("my-skill")).not.toThrow();
|
|
300
|
+
expect(() => validateSkillSlug("skill123")).not.toThrow();
|
|
301
|
+
expect(() => validateSkillSlug("my.skill")).not.toThrow();
|
|
302
|
+
expect(() => validateSkillSlug("my_skill")).not.toThrow();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("rejects path traversal characters", () => {
|
|
306
|
+
expect(() => validateSkillSlug("../../malicious")).toThrow(
|
|
307
|
+
"path traversal",
|
|
308
|
+
);
|
|
309
|
+
expect(() => validateSkillSlug("foo/bar")).toThrow("path traversal");
|
|
310
|
+
expect(() => validateSkillSlug("foo\\bar")).toThrow("path traversal");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("rejects slugs starting with special chars", () => {
|
|
314
|
+
expect(() => validateSkillSlug(".hidden")).toThrow();
|
|
315
|
+
expect(() => validateSkillSlug("-dash")).toThrow();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("rejects empty input", () => {
|
|
319
|
+
expect(() => validateSkillSlug("")).toThrow("Skill slug is required");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ─── fetchSkillFromGitHub ───────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
describe("fetchSkillFromGitHub", () => {
|
|
326
|
+
test("fetches from conventional skills/<slug>/ path", async () => {
|
|
327
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
328
|
+
const urlStr = url.toString();
|
|
329
|
+
// Probe request for skills/my-skill
|
|
330
|
+
if (urlStr.includes("/contents/skills/my-skill")) {
|
|
331
|
+
return Promise.resolve(
|
|
332
|
+
new Response(
|
|
333
|
+
JSON.stringify([
|
|
334
|
+
{
|
|
335
|
+
name: "SKILL.md",
|
|
336
|
+
type: "file",
|
|
337
|
+
download_url: "https://raw.example.com/SKILL.md",
|
|
338
|
+
},
|
|
339
|
+
]),
|
|
340
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
// File download
|
|
345
|
+
if (urlStr.includes("raw.example.com/SKILL.md")) {
|
|
346
|
+
return Promise.resolve(new Response("# My Skill", { status: 200 }));
|
|
347
|
+
}
|
|
348
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const files = await fetchSkillFromGitHub("owner", "repo", "my-skill");
|
|
352
|
+
expect(files["SKILL.md"]).toBe("# My Skill");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("falls back to tree search when skills/<slug>/ returns 404", async () => {
|
|
356
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
357
|
+
const urlStr = url.toString();
|
|
358
|
+
// Probe for conventional path returns 404
|
|
359
|
+
if (urlStr.includes("/contents/skills/csv")) {
|
|
360
|
+
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
361
|
+
}
|
|
362
|
+
// Tree search returns the skill at a non-standard path
|
|
363
|
+
if (urlStr.includes("/git/trees/")) {
|
|
364
|
+
return Promise.resolve(
|
|
365
|
+
new Response(
|
|
366
|
+
JSON.stringify({
|
|
367
|
+
tree: [
|
|
368
|
+
{ path: "examples/skills/csv/SKILL.md", type: "blob" },
|
|
369
|
+
{ path: "examples/skills/csv/scripts/filter.sh", type: "blob" },
|
|
370
|
+
{ path: "README.md", type: "blob" },
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
// Subdirectory listing (must precede parent path check — both use
|
|
378
|
+
// .includes() and the parent path is a prefix of this one)
|
|
379
|
+
if (urlStr.includes("/contents/examples/skills/csv/scripts")) {
|
|
380
|
+
return Promise.resolve(
|
|
381
|
+
new Response(
|
|
382
|
+
JSON.stringify([
|
|
383
|
+
{
|
|
384
|
+
name: "filter.sh",
|
|
385
|
+
type: "file",
|
|
386
|
+
download_url: "https://raw.example.com/filter.sh",
|
|
387
|
+
},
|
|
388
|
+
]),
|
|
389
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
// Contents API for the discovered path
|
|
394
|
+
if (urlStr.includes("/contents/examples/skills/csv")) {
|
|
395
|
+
return Promise.resolve(
|
|
396
|
+
new Response(
|
|
397
|
+
JSON.stringify([
|
|
398
|
+
{
|
|
399
|
+
name: "SKILL.md",
|
|
400
|
+
type: "file",
|
|
401
|
+
download_url: "https://raw.example.com/SKILL.md",
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "scripts",
|
|
405
|
+
type: "dir",
|
|
406
|
+
download_url: null,
|
|
407
|
+
},
|
|
408
|
+
]),
|
|
409
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
// File downloads
|
|
414
|
+
if (urlStr.includes("raw.example.com/SKILL.md")) {
|
|
415
|
+
return Promise.resolve(new Response("# CSV Skill", { status: 200 }));
|
|
416
|
+
}
|
|
417
|
+
if (urlStr.includes("raw.example.com/filter.sh")) {
|
|
418
|
+
return Promise.resolve(
|
|
419
|
+
new Response("#!/bin/bash\necho filter", { status: 200 }),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const files = await fetchSkillFromGitHub("vercel-labs", "bash-tool", "csv");
|
|
426
|
+
expect(files["SKILL.md"]).toBe("# CSV Skill");
|
|
427
|
+
expect(files["scripts/filter.sh"]).toBe("#!/bin/bash\necho filter");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("throws when skill not found in tree either", async () => {
|
|
431
|
+
mockFetchImpl = (url: string | URL | Request) => {
|
|
432
|
+
const urlStr = url.toString();
|
|
433
|
+
if (urlStr.includes("/contents/skills/missing")) {
|
|
434
|
+
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
435
|
+
}
|
|
436
|
+
if (urlStr.includes("/git/trees/")) {
|
|
437
|
+
return Promise.resolve(
|
|
438
|
+
new Response(
|
|
439
|
+
JSON.stringify({ tree: [{ path: "README.md", type: "blob" }] }),
|
|
440
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
await expect(
|
|
448
|
+
fetchSkillFromGitHub("owner", "repo", "missing"),
|
|
449
|
+
).rejects.toThrow("Searched skills/missing/ and the full repo tree");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -777,6 +777,7 @@ describe("Trust Store", () => {
|
|
|
777
777
|
"computer_use_click",
|
|
778
778
|
"computer_use_drag",
|
|
779
779
|
"computer_use_key",
|
|
780
|
+
"computer_use_observe",
|
|
780
781
|
"computer_use_open_app",
|
|
781
782
|
"computer_use_run_applescript",
|
|
782
783
|
"computer_use_scroll",
|
|
@@ -900,6 +901,20 @@ describe("Trust Store", () => {
|
|
|
900
901
|
);
|
|
901
902
|
});
|
|
902
903
|
|
|
904
|
+
test("findHighestPriorityRule matches default ask for computer_use_observe", () => {
|
|
905
|
+
const match = findHighestPriorityRule(
|
|
906
|
+
"computer_use_observe",
|
|
907
|
+
["computer_use_observe:"],
|
|
908
|
+
"/tmp",
|
|
909
|
+
);
|
|
910
|
+
expect(match).not.toBeNull();
|
|
911
|
+
expect(match!.id).toBe("default:ask-computer_use_observe-global");
|
|
912
|
+
expect(match!.decision).toBe("ask");
|
|
913
|
+
expect(match!.priority).toBe(
|
|
914
|
+
DEFAULT_PRIORITY_BY_ID.get("default:ask-computer_use_observe-global")!,
|
|
915
|
+
);
|
|
916
|
+
});
|
|
917
|
+
|
|
903
918
|
test("bootstrap delete rule matches only when workingDir is the workspace dir", () => {
|
|
904
919
|
const workspaceDir = join(testDir, "workspace");
|
|
905
920
|
// Should match when workingDir is the workspace directory — the bootstrap
|
|
@@ -23,7 +23,10 @@ mock.module("../util/logger.js", () => ({
|
|
|
23
23
|
}),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
findContactChannel,
|
|
28
|
+
upsertContact,
|
|
29
|
+
} from "../contacts/contact-store.js";
|
|
27
30
|
import { upsertContactChannel } from "../contacts/contacts-write.js";
|
|
28
31
|
import { getSqlite, initializeDb, resetDb } from "../memory/db.js";
|
|
29
32
|
import { createInvite, revokeInvite } from "../memory/invite-store.js";
|
|
@@ -354,4 +357,32 @@ describe("redeemVoiceInviteCode", () => {
|
|
|
354
357
|
|
|
355
358
|
expect(result).toEqual({ ok: false, reason: "invalid_or_expired" });
|
|
356
359
|
});
|
|
360
|
+
|
|
361
|
+
test("returns invalid_or_expired for a revoked guardian to prevent invite-based reactivation", () => {
|
|
362
|
+
const phone = "+15559998888";
|
|
363
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
364
|
+
|
|
365
|
+
// Pre-create a guardian contact with a revoked phone channel
|
|
366
|
+
upsertContact({
|
|
367
|
+
displayName: "Guardian",
|
|
368
|
+
role: "guardian",
|
|
369
|
+
channels: [
|
|
370
|
+
{
|
|
371
|
+
type: "phone",
|
|
372
|
+
address: phone,
|
|
373
|
+
externalUserId: phone,
|
|
374
|
+
status: "revoked",
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const result = redeemVoiceInviteCode({
|
|
380
|
+
callerExternalUserId: phone,
|
|
381
|
+
sourceChannel: "phone",
|
|
382
|
+
code,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Must reject — guardian channels are managed via the binding flow, not invites
|
|
386
|
+
expect(result).toEqual({ ok: false, reason: "invalid_or_expired" });
|
|
387
|
+
});
|
|
357
388
|
});
|
|
@@ -182,6 +182,57 @@ describe("compactAxTreeHistory", () => {
|
|
|
182
182
|
expect(result).toEqual([]);
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
+
test("counts AX trees per block, not per message", () => {
|
|
186
|
+
// One message has two AX tree blocks — they should count as 2 trees
|
|
187
|
+
const messages: Message[] = [
|
|
188
|
+
{
|
|
189
|
+
role: "user",
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "tool_result",
|
|
193
|
+
tool_use_id: "t1a",
|
|
194
|
+
content: "<ax-tree>\ntree-1a\n</ax-tree>",
|
|
195
|
+
is_error: false,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
type: "tool_result",
|
|
199
|
+
tool_use_id: "t1b",
|
|
200
|
+
content: "<ax-tree>\ntree-1b\n</ax-tree>",
|
|
201
|
+
is_error: false,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
assistantText("ok"),
|
|
206
|
+
axTreeToolResult("t2", "tree-2"),
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const result = compactAxTreeHistory(messages);
|
|
210
|
+
|
|
211
|
+
// 3 total AX tree blocks, keep last 2 → strip only first block (t1a)
|
|
212
|
+
const msg0 = result[0];
|
|
213
|
+
const block0 = msg0.content[0];
|
|
214
|
+
expect(block0.type).toBe("tool_result");
|
|
215
|
+
if (block0.type === "tool_result") {
|
|
216
|
+
expect(block0.content).toContain("<ax_tree_omitted />");
|
|
217
|
+
expect(block0.content).not.toContain("<ax-tree>");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Second block in same message (t1b) should be kept
|
|
221
|
+
const block1 = msg0.content[1];
|
|
222
|
+
expect(block1.type).toBe("tool_result");
|
|
223
|
+
if (block1.type === "tool_result") {
|
|
224
|
+
expect(block1.content).toContain("<ax-tree>");
|
|
225
|
+
expect(block1.content).not.toContain("<ax_tree_omitted />");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Last message (t2) should also be kept
|
|
229
|
+
const lastBlock = result[2].content[0];
|
|
230
|
+
expect(lastBlock.type).toBe("tool_result");
|
|
231
|
+
if (lastBlock.type === "tool_result") {
|
|
232
|
+
expect(lastBlock.content).toContain("<ax-tree>");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
185
236
|
test("is pure — does not mutate input messages", () => {
|
|
186
237
|
const messages: Message[] = [
|
|
187
238
|
axTreeToolResult("t1", "tree-1"),
|