@desplega.ai/agent-swarm 1.98.0 → 1.99.0
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/README.md +1 -0
- package/openapi.json +20 -1
- package/package.json +5 -5
- package/src/be/memory/link-resolver.ts +226 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -2
- package/src/be/memory/raters/retrieval.ts +15 -4
- package/src/be/memory/raters/store.ts +4 -2
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
- package/src/be/modelsdev-cache.ts +5 -0
- package/src/be/pricing-refresh.ts +189 -0
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-pricing.ts +5 -3
- package/src/commands/profile-sync.ts +83 -17
- package/src/commands/runner.ts +35 -3
- package/src/e2b/dispatch.ts +5 -0
- package/src/hooks/hook.ts +21 -5
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +116 -7
- package/src/providers/claude-adapter.ts +13 -2
- package/src/providers/pricing-sources.md +27 -9
- package/src/providers/types.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +5 -1
- package/src/scripts-runtime/types/stdlib.d.ts +2 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
- package/src/server.ts +2 -0
- package/src/slack/blocks.ts +58 -12
- package/src/slack/responses.ts +35 -12
- package/src/slack/watcher.ts +28 -7
- package/src/tests/internal-ai/complete-structured.test.ts +34 -1
- package/src/tests/memory-http-recall-gating.test.ts +172 -0
- package/src/tests/memory-link-resolver.test.ts +92 -0
- package/src/tests/opencode-adapter.test.ts +3 -0
- package/src/tests/pricing-refresh.test.ts +156 -0
- package/src/tests/profile-sync.test.ts +186 -0
- package/src/tests/scripts-mcp-e2e.test.ts +1 -1
- package/src/tests/slack-blocks.test.ts +48 -1
- package/src/tools/memory-get.ts +22 -1
- package/src/tools/memory-search.ts +8 -1
- package/src/tools/utils.ts +10 -0
- package/src/types.ts +2 -0
- package/src/utils/internal-ai/complete-structured.ts +10 -1
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
7
|
+
import type { AgentMemory } from "../types";
|
|
8
|
+
|
|
9
|
+
const memoryId = randomUUID();
|
|
10
|
+
const agentId = randomUUID();
|
|
11
|
+
const sourceTaskId = randomUUID();
|
|
12
|
+
const TEST_DB_PATH = "./test-memory-http-recall-gating.sqlite";
|
|
13
|
+
|
|
14
|
+
const memory: AgentMemory = {
|
|
15
|
+
id: memoryId,
|
|
16
|
+
agentId,
|
|
17
|
+
content: "UI browse/search memory fixture",
|
|
18
|
+
name: "ui-memory-fixture",
|
|
19
|
+
scope: "agent",
|
|
20
|
+
source: "manual",
|
|
21
|
+
sourcePath: null,
|
|
22
|
+
sourceTaskId: null,
|
|
23
|
+
chunkIndex: 0,
|
|
24
|
+
totalChunks: 1,
|
|
25
|
+
tags: [],
|
|
26
|
+
contextKey: null,
|
|
27
|
+
createdAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
|
|
28
|
+
updatedAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
mock.module("../be/memory", () => ({
|
|
32
|
+
getEmbeddingProvider: () => ({
|
|
33
|
+
name: "test-embedding",
|
|
34
|
+
dimensions: 3,
|
|
35
|
+
embed: async () => new Float32Array([1, 0, 0]),
|
|
36
|
+
embedBatch: async (texts: string[]) => texts.map(() => new Float32Array([1, 0, 0])),
|
|
37
|
+
}),
|
|
38
|
+
getMemoryStore: () => ({
|
|
39
|
+
get: () => memory,
|
|
40
|
+
search: () => [
|
|
41
|
+
{
|
|
42
|
+
...memory,
|
|
43
|
+
similarity: 0.95,
|
|
44
|
+
rawSimilarity: 0.95,
|
|
45
|
+
compositeScore: 0.95,
|
|
46
|
+
accessCount: 0,
|
|
47
|
+
expiresAt: null,
|
|
48
|
+
embeddingModel: "test-embedding",
|
|
49
|
+
alpha: 1,
|
|
50
|
+
beta: 1,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const { handleMemory } = await import("../http/memory");
|
|
57
|
+
|
|
58
|
+
type ResponseCapture = {
|
|
59
|
+
statusCode: number;
|
|
60
|
+
body: any;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function makeReq(
|
|
64
|
+
method: string,
|
|
65
|
+
url: string,
|
|
66
|
+
body?: unknown,
|
|
67
|
+
headers: Record<string, string> = {},
|
|
68
|
+
): IncomingMessage {
|
|
69
|
+
const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
|
|
70
|
+
const req = Readable.from(chunks) as IncomingMessage;
|
|
71
|
+
req.method = method;
|
|
72
|
+
req.url = url;
|
|
73
|
+
req.headers = headers;
|
|
74
|
+
return req;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeRes(capture: ResponseCapture): ServerResponse {
|
|
78
|
+
return {
|
|
79
|
+
writeHead(statusCode: number) {
|
|
80
|
+
capture.statusCode = statusCode;
|
|
81
|
+
return this;
|
|
82
|
+
},
|
|
83
|
+
end(chunk?: unknown) {
|
|
84
|
+
capture.body = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
|
|
85
|
+
return this;
|
|
86
|
+
},
|
|
87
|
+
} as ServerResponse;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function callMemoryRoute(
|
|
91
|
+
method: string,
|
|
92
|
+
url: string,
|
|
93
|
+
pathSegments: string[],
|
|
94
|
+
body?: unknown,
|
|
95
|
+
headers: Record<string, string> = {},
|
|
96
|
+
): Promise<ResponseCapture> {
|
|
97
|
+
const capture: ResponseCapture = { statusCode: 0, body: null };
|
|
98
|
+
const handled = await handleMemory(
|
|
99
|
+
makeReq(method, url, body, headers),
|
|
100
|
+
makeRes(capture),
|
|
101
|
+
pathSegments,
|
|
102
|
+
agentId,
|
|
103
|
+
);
|
|
104
|
+
expect(handled).toBe(true);
|
|
105
|
+
return capture;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function countRetrievals(): number {
|
|
109
|
+
return getDb().prepare<{ n: number }, []>("SELECT COUNT(*) AS n FROM memory_retrieval").get()!.n;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeAll(async () => {
|
|
113
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
114
|
+
try {
|
|
115
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
initDb(TEST_DB_PATH);
|
|
120
|
+
createAgent({ id: agentId, name: "HTTP Memory Gating Agent", isLead: false, status: "idle" });
|
|
121
|
+
const nowIso = new Date().toISOString();
|
|
122
|
+
getDb()
|
|
123
|
+
.prepare(
|
|
124
|
+
`INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
|
|
125
|
+
VALUES (?, ?, ?, 'in_progress', 'mcp', ?, ?)`,
|
|
126
|
+
)
|
|
127
|
+
.run(sourceTaskId, agentId, "HTTP memory recall gating task", nowIso, nowIso);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
getDb().run("DELETE FROM memory_retrieval");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterAll(async () => {
|
|
135
|
+
closeDb();
|
|
136
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
137
|
+
try {
|
|
138
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("memory HTTP recall capture gating", () => {
|
|
144
|
+
test("POST /api/memory/search accepts UI calls without intent and does not record retrievals", async () => {
|
|
145
|
+
const response = await callMemoryRoute(
|
|
146
|
+
"POST",
|
|
147
|
+
"/api/memory/search",
|
|
148
|
+
["api", "memory", "search"],
|
|
149
|
+
{ query: "UI browse/search", limit: 5 },
|
|
150
|
+
{ "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(response.statusCode).toBe(200);
|
|
154
|
+
expect(response.body.results).toHaveLength(1);
|
|
155
|
+
expect(response.body.results[0].id).toBe(memoryId);
|
|
156
|
+
expect(countRetrievals()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("GET /api/memory/:id accepts UI calls without intent and does not record retrievals", async () => {
|
|
160
|
+
const response = await callMemoryRoute(
|
|
161
|
+
"GET",
|
|
162
|
+
`/api/memory/${memoryId}`,
|
|
163
|
+
["api", "memory", memoryId],
|
|
164
|
+
undefined,
|
|
165
|
+
{ "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(response.statusCode).toBe(200);
|
|
169
|
+
expect(response.body.memory.id).toBe(memoryId);
|
|
170
|
+
expect(countRetrievals()).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveLinks } from "../be/memory/link-resolver";
|
|
3
|
+
|
|
4
|
+
describe("resolveLinks", () => {
|
|
5
|
+
test("extracts wikilinks from content", () => {
|
|
6
|
+
const links = resolveLinks("See [[auth-fix-pattern]] and [[pr585-codex-binary]] for context.");
|
|
7
|
+
expect(links).toHaveLength(2);
|
|
8
|
+
expect(links[0]).toMatchObject({
|
|
9
|
+
linkType: "wikilink",
|
|
10
|
+
targetKind: "memory",
|
|
11
|
+
targetId: "auth-fix-pattern",
|
|
12
|
+
resolver: "wikilink",
|
|
13
|
+
});
|
|
14
|
+
expect(links[1]).toMatchObject({
|
|
15
|
+
linkType: "wikilink",
|
|
16
|
+
targetKind: "memory",
|
|
17
|
+
targetId: "pr585-codex-binary",
|
|
18
|
+
resolver: "wikilink",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("extracts PR references with hash notation", () => {
|
|
23
|
+
const links = resolveLinks("Fixed in #696 and PR #470.");
|
|
24
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
25
|
+
expect(prLinks.length).toBeGreaterThanOrEqual(2);
|
|
26
|
+
const ids = prLinks.map((l) => l.targetId);
|
|
27
|
+
expect(ids).toContain("pr:696");
|
|
28
|
+
expect(ids).toContain("pr:470");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("extracts full GitHub PR URLs", () => {
|
|
32
|
+
const links = resolveLinks(
|
|
33
|
+
"See https://github.com/desplega-ai/agent-swarm/pull/763 for the fix.",
|
|
34
|
+
);
|
|
35
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
36
|
+
expect(prLinks).toHaveLength(1);
|
|
37
|
+
expect(prLinks[0]).toMatchObject({
|
|
38
|
+
linkType: "pr",
|
|
39
|
+
targetKind: "pr",
|
|
40
|
+
targetId: "github:desplega-ai/agent-swarm#763",
|
|
41
|
+
resolver: "pr-url",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("extracts agent-fs paths", () => {
|
|
46
|
+
const links = resolveLinks(
|
|
47
|
+
"Plan at live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba-4eee-4472-8b3b-359c4ed6bfbb/thoughts/plan.md",
|
|
48
|
+
);
|
|
49
|
+
const fsLinks = links.filter((l) => l.linkType === "agent-fs-file");
|
|
50
|
+
expect(fsLinks).toHaveLength(1);
|
|
51
|
+
expect(fsLinks[0]!.targetKind).toBe("agent-fs-file");
|
|
52
|
+
expect(fsLinks[0]!.resolver).toBe("agent-fs-path");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("extracts agent-ui page links", () => {
|
|
56
|
+
const links = resolveLinks(
|
|
57
|
+
"See app.agent-swarm.dev/pages/abc12345-1234-1234-1234-123456789abc",
|
|
58
|
+
);
|
|
59
|
+
const uiLinks = links.filter((l) => l.linkType === "agent-ui");
|
|
60
|
+
expect(uiLinks).toHaveLength(1);
|
|
61
|
+
expect(uiLinks[0]).toMatchObject({
|
|
62
|
+
targetKind: "agent-ui",
|
|
63
|
+
targetId: "page:abc12345-1234-1234-1234-123456789abc",
|
|
64
|
+
resolver: "agent-ui-page",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("deduplicates PR references", () => {
|
|
69
|
+
const links = resolveLinks("PR #696, see also PR #696 again, and #696 once more.");
|
|
70
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
71
|
+
const ids696 = prLinks.filter((l) => l.targetId === "pr:696");
|
|
72
|
+
expect(ids696).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns empty array for content without links", () => {
|
|
76
|
+
const links = resolveLinks("This is plain text with no links or references.");
|
|
77
|
+
expect(links).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("handles mixed content with multiple link types", () => {
|
|
81
|
+
const content = `
|
|
82
|
+
See [[memory-search-fix]] for context.
|
|
83
|
+
PR #696 fixed the embedding issue.
|
|
84
|
+
Plan: live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba/thoughts/plan.md
|
|
85
|
+
`;
|
|
86
|
+
const links = resolveLinks(content);
|
|
87
|
+
const types = new Set(links.map((l) => l.linkType));
|
|
88
|
+
expect(types.has("wikilink")).toBe(true);
|
|
89
|
+
expect(types.has("pr")).toBe(true);
|
|
90
|
+
expect(types.has("agent-fs-file")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -682,12 +682,15 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
|
682
682
|
|
|
683
683
|
expect(lastCreateOpencodeConfig).toBeDefined();
|
|
684
684
|
const opts = lastCreateOpencodeConfig as {
|
|
685
|
+
timeout?: number;
|
|
685
686
|
config?: {
|
|
686
687
|
model?: string;
|
|
687
688
|
mcp?: Record<string, unknown>;
|
|
688
689
|
permission?: Record<string, string>;
|
|
689
690
|
};
|
|
690
691
|
};
|
|
692
|
+
// Server-start timeout must override the SDK's 5s default (E2B cold-start flake)
|
|
693
|
+
expect(opts.timeout).toBe(30_000);
|
|
691
694
|
expect(opts.config?.model).toBe("claude-sonnet-4-6");
|
|
692
695
|
expect(opts.config?.mcp?.swarm).toBeDefined();
|
|
693
696
|
const swarm = opts.config?.mcp?.swarm as {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, getActivePricingRow, getDb, getLogsByEventType, initDb } from "../be/db";
|
|
4
|
+
import type { ModelsDevCache } from "../be/modelsdev-cache";
|
|
5
|
+
import { refreshPricingFromModelsDev } from "../be/pricing-refresh";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = "./test-pricing-refresh.sqlite";
|
|
8
|
+
|
|
9
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
10
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
11
|
+
try {
|
|
12
|
+
await unlink(path + suffix);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function responseFor(cache: ModelsDevCache, etag = '"test-etag"'): Response {
|
|
20
|
+
return new Response(JSON.stringify(cache), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: { "content-type": "application/json", etag },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function openAiCache(input: number, output: number): ModelsDevCache {
|
|
27
|
+
return {
|
|
28
|
+
openai: {
|
|
29
|
+
models: {
|
|
30
|
+
"gpt-refresh-test": {
|
|
31
|
+
cost: { input, output },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
40
|
+
initDb(TEST_DB_PATH);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
closeDb();
|
|
45
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
db.prepare("DELETE FROM pricing").run();
|
|
51
|
+
db.prepare("DELETE FROM agent_log WHERE eventType LIKE 'pricing.refresh%'").run();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("models.dev runtime pricing refresh", () => {
|
|
55
|
+
test("inserts a new effective row when upstream price changes and no-ops identical prices", async () => {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
db.prepare(
|
|
58
|
+
`INSERT INTO pricing
|
|
59
|
+
(provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
60
|
+
VALUES ('codex', 'gpt-refresh-test', 'input', 0, 1, 0, 0)`,
|
|
61
|
+
).run();
|
|
62
|
+
|
|
63
|
+
const first = await refreshPricingFromModelsDev({
|
|
64
|
+
now: 1_000,
|
|
65
|
+
fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-1"'),
|
|
66
|
+
});
|
|
67
|
+
expect(first.status).toBe("refreshed");
|
|
68
|
+
expect(first.candidateRows).toBe(4);
|
|
69
|
+
expect(first.inserted).toBe(4);
|
|
70
|
+
expect(first.unchanged).toBe(0);
|
|
71
|
+
|
|
72
|
+
const activeChanged = getActivePricingRow("codex", "gpt-refresh-test", "input", 1_000);
|
|
73
|
+
expect(activeChanged?.effectiveFrom).toBe(1_000);
|
|
74
|
+
expect(activeChanged?.pricePerMillionUsd).toBe(2);
|
|
75
|
+
|
|
76
|
+
const second = await refreshPricingFromModelsDev({
|
|
77
|
+
now: 2_000,
|
|
78
|
+
fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-2"'),
|
|
79
|
+
});
|
|
80
|
+
expect(second.inserted).toBe(0);
|
|
81
|
+
expect(second.unchanged).toBe(4);
|
|
82
|
+
|
|
83
|
+
const rows = db
|
|
84
|
+
.prepare<{ effective_from: number }, []>(
|
|
85
|
+
`SELECT effective_from FROM pricing
|
|
86
|
+
WHERE provider = 'codex'
|
|
87
|
+
AND model = 'gpt-refresh-test'
|
|
88
|
+
AND token_class = 'input'
|
|
89
|
+
ORDER BY effective_from`,
|
|
90
|
+
)
|
|
91
|
+
.all();
|
|
92
|
+
expect(rows.map((row) => row.effective_from)).toEqual([0, 1_000]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("sends If-None-Match and short-circuits on HTTP 304", async () => {
|
|
96
|
+
await refreshPricingFromModelsDev({
|
|
97
|
+
now: 1_000,
|
|
98
|
+
fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-304"'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let ifNoneMatch: string | null = null;
|
|
102
|
+
const result = await refreshPricingFromModelsDev({
|
|
103
|
+
now: 2_000,
|
|
104
|
+
fetchImpl: async (_input, init) => {
|
|
105
|
+
const headers = new Headers(init?.headers);
|
|
106
|
+
ifNoneMatch = headers.get("if-none-match");
|
|
107
|
+
return new Response(null, { status: 304 });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(ifNoneMatch).toBe('"etag-304"');
|
|
112
|
+
expect(result.status).toBe("not_modified");
|
|
113
|
+
expect(result.inserted).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("prunes pricing history to the latest two effective rows per triple", async () => {
|
|
117
|
+
const db = getDb();
|
|
118
|
+
const insert = db.prepare(
|
|
119
|
+
`INSERT INTO pricing
|
|
120
|
+
(provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
121
|
+
VALUES ('codex', 'gpt-refresh-test', 'input', ?, ?, 0, 0)`,
|
|
122
|
+
);
|
|
123
|
+
insert.run(1_000, 1);
|
|
124
|
+
insert.run(2_000, 2);
|
|
125
|
+
insert.run(3_000, 3);
|
|
126
|
+
|
|
127
|
+
const result = await refreshPricingFromModelsDev({
|
|
128
|
+
now: 4_000,
|
|
129
|
+
fetchImpl: async () => responseFor(openAiCache(3, 8), '"etag-prune"'),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(result.pruned).toBe(1);
|
|
133
|
+
const rows = db
|
|
134
|
+
.prepare<{ effective_from: number }, []>(
|
|
135
|
+
`SELECT effective_from FROM pricing
|
|
136
|
+
WHERE provider = 'codex'
|
|
137
|
+
AND model = 'gpt-refresh-test'
|
|
138
|
+
AND token_class = 'input'
|
|
139
|
+
ORDER BY effective_from`,
|
|
140
|
+
)
|
|
141
|
+
.all();
|
|
142
|
+
expect(rows.map((row) => row.effective_from)).toEqual([2_000, 3_000]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("writes scrubbed audit log entries for successful refreshes", async () => {
|
|
146
|
+
await refreshPricingFromModelsDev({
|
|
147
|
+
now: 1_000,
|
|
148
|
+
fetchImpl: async () => responseFor(openAiCache(2, 8), '"etag-log"'),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const logs = getLogsByEventType("pricing.refresh");
|
|
152
|
+
expect(logs).toHaveLength(1);
|
|
153
|
+
expect(logs[0]?.newValue).toContain("inserted=4");
|
|
154
|
+
expect(logs[0]?.metadata).toContain('"etag":"\\"etag-log\\""');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -3,9 +3,12 @@ import {
|
|
|
3
3
|
buildIdentityPayload,
|
|
4
4
|
CLAUDE_MD_PATH,
|
|
5
5
|
collectProfilePayloads,
|
|
6
|
+
contentSha256,
|
|
6
7
|
extractSetupScriptContent,
|
|
7
8
|
type FileReader,
|
|
9
|
+
IDENTITY_BASELINES_PATH,
|
|
8
10
|
IDENTITY_MD_PATH,
|
|
11
|
+
type IdentityBaselines,
|
|
9
12
|
postProfileUpdate,
|
|
10
13
|
resolveClaudeMdPath,
|
|
11
14
|
SETUP_SCRIPT_PATH,
|
|
@@ -280,3 +283,186 @@ describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
|
280
283
|
}
|
|
281
284
|
});
|
|
282
285
|
});
|
|
286
|
+
|
|
287
|
+
// ── Baseline comparison tests ─────────────────────────────────────────────
|
|
288
|
+
// These test the fix for Lead's update-profile edits getting clobbered by
|
|
289
|
+
// the worker's session-end sync. When a file's content hash matches the
|
|
290
|
+
// baseline recorded at session start, it means the agent didn't modify it,
|
|
291
|
+
// so session_sync skips it to preserve any DB-side edits made by Lead.
|
|
292
|
+
|
|
293
|
+
describe("buildIdentityPayload (baseline comparison)", () => {
|
|
294
|
+
const baselines: IdentityBaselines = {
|
|
295
|
+
soulMd: contentSha256(LONG),
|
|
296
|
+
identityMd: contentSha256(LONG),
|
|
297
|
+
toolsMd: contentSha256("original tools"),
|
|
298
|
+
heartbeatMd: contentSha256("original heartbeat"),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
test("skips files whose hash matches the baseline (unchanged during session)", () => {
|
|
302
|
+
const payload = buildIdentityPayload(
|
|
303
|
+
{
|
|
304
|
+
soulMd: LONG,
|
|
305
|
+
identityMd: LONG,
|
|
306
|
+
toolsMd: "original tools",
|
|
307
|
+
heartbeatMd: "original heartbeat",
|
|
308
|
+
},
|
|
309
|
+
baselines,
|
|
310
|
+
);
|
|
311
|
+
expect(payload).toEqual({});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("includes files whose content differs from the baseline (modified during session)", () => {
|
|
315
|
+
const modifiedSoul = `${LONG} — agent added this`;
|
|
316
|
+
const payload = buildIdentityPayload(
|
|
317
|
+
{
|
|
318
|
+
soulMd: modifiedSoul,
|
|
319
|
+
identityMd: LONG, // unchanged
|
|
320
|
+
toolsMd: "modified tools",
|
|
321
|
+
heartbeatMd: "original heartbeat", // unchanged
|
|
322
|
+
},
|
|
323
|
+
baselines,
|
|
324
|
+
);
|
|
325
|
+
expect(payload.soulMd).toBe(modifiedSoul);
|
|
326
|
+
expect(payload.identityMd).toBeUndefined();
|
|
327
|
+
expect(payload.toolsMd).toBe("modified tools");
|
|
328
|
+
expect(payload.heartbeatMd).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("without baselines (null), all files sync as before (backwards compat)", () => {
|
|
332
|
+
const payload = buildIdentityPayload(
|
|
333
|
+
{ soulMd: LONG, identityMd: LONG, toolsMd: "tools" },
|
|
334
|
+
null,
|
|
335
|
+
);
|
|
336
|
+
expect(payload.soulMd).toBe(LONG);
|
|
337
|
+
expect(payload.identityMd).toBe(LONG);
|
|
338
|
+
expect(payload.toolsMd).toBe("tools");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("without baselines (undefined), all files sync as before (backwards compat)", () => {
|
|
342
|
+
const payload = buildIdentityPayload({ soulMd: LONG, identityMd: LONG }, undefined);
|
|
343
|
+
expect(payload.soulMd).toBe(LONG);
|
|
344
|
+
expect(payload.identityMd).toBe(LONG);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("a field missing from baselines is still synced (partial baseline)", () => {
|
|
348
|
+
const partial: IdentityBaselines = { soulMd: contentSha256(LONG) };
|
|
349
|
+
const payload = buildIdentityPayload({ soulMd: LONG, identityMd: LONG }, partial);
|
|
350
|
+
expect(payload.soulMd).toBeUndefined(); // matches baseline → skipped
|
|
351
|
+
expect(payload.identityMd).toBe(LONG); // no baseline → synced
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("collectProfilePayloads (baseline integration)", () => {
|
|
356
|
+
const reader = (files: Record<string, string>): FileReader => {
|
|
357
|
+
return async (path: string) => files[path];
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
test("session_sync skips unchanged identity files when baselines exist", async () => {
|
|
361
|
+
const identityContent = LONG;
|
|
362
|
+
const toolsContent = "original tools";
|
|
363
|
+
const modifiedToolsContent = "modified tools";
|
|
364
|
+
|
|
365
|
+
const baselines: IdentityBaselines = {
|
|
366
|
+
soulMd: contentSha256(identityContent),
|
|
367
|
+
identityMd: contentSha256(identityContent),
|
|
368
|
+
toolsMd: contentSha256(toolsContent),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const files = reader({
|
|
372
|
+
[SOUL_MD_PATH]: identityContent,
|
|
373
|
+
[IDENTITY_MD_PATH]: identityContent,
|
|
374
|
+
[TOOLS_MD_PATH]: modifiedToolsContent,
|
|
375
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const payloads = await collectProfilePayloads(["identity"], "session_sync", files);
|
|
379
|
+
expect(payloads).toHaveLength(1);
|
|
380
|
+
expect(payloads[0]?.body.toolsMd).toBe(modifiedToolsContent);
|
|
381
|
+
expect(payloads[0]?.body.soulMd).toBeUndefined();
|
|
382
|
+
expect(payloads[0]?.body.identityMd).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("self_edit bypasses baselines (agent explicitly changed the file)", async () => {
|
|
386
|
+
const identityContent = LONG;
|
|
387
|
+
|
|
388
|
+
const files = reader({
|
|
389
|
+
[SOUL_MD_PATH]: identityContent,
|
|
390
|
+
[IDENTITY_MD_PATH]: identityContent,
|
|
391
|
+
[TOOLS_MD_PATH]: "tools",
|
|
392
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({
|
|
393
|
+
soulMd: contentSha256(identityContent),
|
|
394
|
+
identityMd: contentSha256(identityContent),
|
|
395
|
+
toolsMd: contentSha256("tools"),
|
|
396
|
+
}),
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
|
|
400
|
+
expect(payloads).toHaveLength(1);
|
|
401
|
+
// self_edit should include ALL files regardless of baselines
|
|
402
|
+
expect(payloads[0]?.body.soulMd).toBe(identityContent);
|
|
403
|
+
expect(payloads[0]?.body.identityMd).toBe(identityContent);
|
|
404
|
+
expect(payloads[0]?.body.toolsMd).toBe("tools");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("session_sync skips unchanged CLAUDE.md when baseline matches", async () => {
|
|
408
|
+
const claudeContent = "original claude md";
|
|
409
|
+
const baselines: IdentityBaselines = { claudeMd: contentSha256(claudeContent) };
|
|
410
|
+
|
|
411
|
+
const files = reader({
|
|
412
|
+
[CLAUDE_MD_PATH]: claudeContent,
|
|
413
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
417
|
+
expect(payloads).toEqual([]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("session_sync syncs modified CLAUDE.md even when baselines exist", async () => {
|
|
421
|
+
const baselines: IdentityBaselines = { claudeMd: contentSha256("original") };
|
|
422
|
+
|
|
423
|
+
const files = reader({
|
|
424
|
+
[CLAUDE_MD_PATH]: "modified claude md",
|
|
425
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
429
|
+
expect(payloads).toHaveLength(1);
|
|
430
|
+
expect(payloads[0]?.body.claudeMd).toBe("modified claude md");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("session_sync proceeds normally when baselines file is missing", async () => {
|
|
434
|
+
const files = reader({
|
|
435
|
+
[TOOLS_MD_PATH]: "tools content",
|
|
436
|
+
// No IDENTITY_BASELINES_PATH → baselines will be null → no skipping
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const payloads = await collectProfilePayloads(["identity"], "session_sync", files);
|
|
440
|
+
expect(payloads).toHaveLength(1);
|
|
441
|
+
expect(payloads[0]?.body.toolsMd).toBe("tools content");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("all identity files unchanged → no identity payload at all", async () => {
|
|
445
|
+
const baselines: IdentityBaselines = {
|
|
446
|
+
soulMd: contentSha256(LONG),
|
|
447
|
+
identityMd: contentSha256(LONG),
|
|
448
|
+
toolsMd: contentSha256("tools"),
|
|
449
|
+
heartbeatMd: contentSha256("heartbeat"),
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const files = reader({
|
|
453
|
+
[SOUL_MD_PATH]: LONG,
|
|
454
|
+
[IDENTITY_MD_PATH]: LONG,
|
|
455
|
+
[TOOLS_MD_PATH]: "tools",
|
|
456
|
+
"/workspace/HEARTBEAT.md": "heartbeat",
|
|
457
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const payloads = await collectProfilePayloads(
|
|
461
|
+
["identity", "claude", "setup"],
|
|
462
|
+
"session_sync",
|
|
463
|
+
files,
|
|
464
|
+
);
|
|
465
|
+
// No identity payload (all skipped), no claude or setup (files missing)
|
|
466
|
+
expect(payloads).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -330,7 +330,7 @@ describe("script_ MCP HTTP proxy tools", () => {
|
|
|
330
330
|
const tools = buildToolServer();
|
|
331
331
|
const source = `
|
|
332
332
|
import type { ScriptContext, SwarmSdk } from "swarm-sdk";
|
|
333
|
-
const compileOnly = (swarm: SwarmSdk) => swarm.memory_search({ query: "foo" });
|
|
333
|
+
const compileOnly = (swarm: SwarmSdk) => swarm.memory_search({ query: "foo", intent: "test" });
|
|
334
334
|
export default async (_args: unknown, ctx: ScriptContext) => {
|
|
335
335
|
void compileOnly;
|
|
336
336
|
return { hasMemorySearch: typeof ctx.swarm.memory_search === "function" };
|