@desplega.ai/agent-swarm 1.97.0 → 1.98.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/openapi.json +1 -1
- package/package.json +3 -3
- package/src/be/modelsdev-cache.ts +5 -0
- package/src/be/pricing-refresh.ts +189 -0
- package/src/be/seed-pricing.ts +5 -3
- package/src/commands/profile-sync.ts +83 -17
- package/src/commands/runner.ts +23 -1
- package/src/hooks/hook.ts +21 -5
- package/src/http/index.ts +2 -0
- package/src/providers/opencode-adapter.ts +6 -0
- package/src/providers/pricing-sources.md +27 -9
- 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/pricing-refresh.test.ts +156 -0
- package/src/tests/profile-sync.test.ts +186 -0
- package/src/tests/slack-blocks.test.ts +48 -1
- package/src/types.ts +2 -0
package/src/slack/blocks.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import type { AgentTaskStatus, TaskAttachment } from "../types";
|
|
9
9
|
import { buildAgentFsLiveUrl, getAppUrl } from "../utils/constants";
|
|
10
10
|
|
|
11
|
-
// Slack limits section text to 3000 chars; we use 2900 for safety
|
|
12
|
-
const MAX_SECTION_LENGTH = 2900;
|
|
11
|
+
// Slack limits section text to 3000 chars; we use 2900 for safety.
|
|
12
|
+
export const MAX_SECTION_LENGTH = 2900;
|
|
13
|
+
export const MAX_BLOCKS_PER_COMPLETION_MESSAGE = 45;
|
|
13
14
|
|
|
14
15
|
// biome-ignore lint/suspicious/noExplicitAny: Slack block types are complex unions; we build plain objects
|
|
15
16
|
type SlackBlock = any;
|
|
@@ -38,24 +39,33 @@ export function getTaskUrl(taskId: string): string {
|
|
|
38
39
|
* Convert GitHub-flavored markdown to Slack mrkdwn format.
|
|
39
40
|
*
|
|
40
41
|
* Key differences:
|
|
41
|
-
* - GitHub: **bold**, *italic*, ~~strike~~, [text](url)
|
|
42
|
-
* - Slack: *bold*, _italic_, ~strike~,
|
|
42
|
+
* - GitHub: **bold**, __bold__, *italic*, ~~strike~~, ### Header, [text](url)
|
|
43
|
+
* - Slack: *bold*, *bold*, _italic_, ~strike~, *Header*, text (url)
|
|
43
44
|
*/
|
|
44
45
|
export function markdownToSlack(text: string): string {
|
|
45
46
|
return (
|
|
46
47
|
text
|
|
48
|
+
// Images: keep alt text and expose the URL plainly.
|
|
49
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, url) =>
|
|
50
|
+
alt ? `${alt} (${url})` : url,
|
|
51
|
+
)
|
|
52
|
+
// Links: keep a plain URL fallback instead of Slack's <url|text> shortcut.
|
|
53
|
+
// Slack block auto-promotion has historically rejected that shortcut with
|
|
54
|
+
// invalid_blocks, while plain URLs remain copyable and auto-unfurlable.
|
|
55
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, "$1 ($2)")
|
|
47
56
|
// Headers to bold placeholder (# Header -> bold, protected from italic)
|
|
48
57
|
.replace(/^#{1,6}\s+(.+)$/gm, "\uE000$1\uE001")
|
|
49
58
|
// Bold **text** -> placeholder (to avoid italic chain converting *bold* to _italic_)
|
|
50
59
|
.replace(/\*\*(.+?)\*\*/g, "\uE000$1\uE001")
|
|
60
|
+
// Bold __text__ -> placeholder
|
|
61
|
+
.replace(/__(.+?)__/g, "\uE000$1\uE001")
|
|
51
62
|
// Italic *text* -> _text_ (single asterisks, now safe from bold placeholders)
|
|
52
63
|
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_")
|
|
64
|
+
// Italic _text_ already matches Slack mrkdwn; leave it alone.
|
|
53
65
|
// Restore bold from placeholder -> *text*
|
|
54
66
|
.replace(/\uE000(.+?)\uE001/g, "*$1*")
|
|
55
67
|
// Strikethrough ~~text~~ -> ~text~
|
|
56
68
|
.replace(/~~(.+?)~~/g, "~$1~")
|
|
57
|
-
// Links [text](url) -> <url|text>
|
|
58
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>")
|
|
59
69
|
// Inline code already works the same
|
|
60
70
|
// Bullet points already work the same
|
|
61
71
|
// Remove excessive blank lines
|
|
@@ -66,7 +76,7 @@ export function markdownToSlack(text: string): string {
|
|
|
66
76
|
/**
|
|
67
77
|
* Split text into chunks that fit within Slack's section text limit.
|
|
68
78
|
*/
|
|
69
|
-
function
|
|
79
|
+
export function splitSlackSectionText(text: string): string[] {
|
|
70
80
|
if (text.length <= MAX_SECTION_LENGTH) return [text];
|
|
71
81
|
|
|
72
82
|
const chunks: string[] = [];
|
|
@@ -248,17 +258,50 @@ export function buildCompletedBlocks(opts: {
|
|
|
248
258
|
|
|
249
259
|
// Only include body if not minimal (agent didn't reply via slack-reply)
|
|
250
260
|
if (!opts.minimal) {
|
|
251
|
-
for (const chunk of
|
|
261
|
+
for (const chunk of splitSlackSectionText(opts.body)) {
|
|
252
262
|
blocks.push(sectionBlock(chunk));
|
|
253
263
|
}
|
|
254
264
|
} else if (opts.trailer && opts.trailer.length > 0) {
|
|
255
|
-
for (const chunk of
|
|
265
|
+
for (const chunk of splitSlackSectionText(opts.trailer)) {
|
|
256
266
|
blocks.push(sectionBlock(chunk));
|
|
257
267
|
}
|
|
258
268
|
}
|
|
259
269
|
return blocks;
|
|
260
270
|
}
|
|
261
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Build one or more completed-task block payloads. The first payload carries
|
|
274
|
+
* the normal completion header; continuation payloads carry a compact part
|
|
275
|
+
* header. This keeps long completion summaries inside Slack's block limits
|
|
276
|
+
* without dropping body text.
|
|
277
|
+
*/
|
|
278
|
+
export function buildCompletedBlockBatches(opts: Parameters<typeof buildCompletedBlocks>[0]) {
|
|
279
|
+
const allBlocks = buildCompletedBlocks(opts);
|
|
280
|
+
if (allBlocks.length <= MAX_BLOCKS_PER_COMPLETION_MESSAGE) return [allBlocks];
|
|
281
|
+
|
|
282
|
+
const header = allBlocks[0];
|
|
283
|
+
const bodyBlocks = allBlocks.slice(1);
|
|
284
|
+
const bodyLimit = MAX_BLOCKS_PER_COMPLETION_MESSAGE - 1;
|
|
285
|
+
const batches: SlackBlock[][] = [];
|
|
286
|
+
|
|
287
|
+
for (let start = 0; start < bodyBlocks.length; start += bodyLimit) {
|
|
288
|
+
const partBlocks = bodyBlocks.slice(start, start + bodyLimit);
|
|
289
|
+
if (start === 0) {
|
|
290
|
+
batches.push([header, ...partBlocks]);
|
|
291
|
+
} else {
|
|
292
|
+
const part = batches.length + 1;
|
|
293
|
+
batches.push([
|
|
294
|
+
sectionBlock(
|
|
295
|
+
`↳ *${opts.agentName}* (${getTaskLink(opts.taskId)}) continued · part ${part}`,
|
|
296
|
+
),
|
|
297
|
+
...partBlocks,
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return batches;
|
|
303
|
+
}
|
|
304
|
+
|
|
262
305
|
/**
|
|
263
306
|
* Build blocks for a failed task response.
|
|
264
307
|
* Single-line header, then error in code block.
|
|
@@ -369,7 +412,10 @@ function truncateOutput(text: string): string {
|
|
|
369
412
|
const sentenceEnd = text.search(/\.\s/);
|
|
370
413
|
const firstSentence = sentenceEnd !== -1 ? text.slice(0, sentenceEnd + 1) : text;
|
|
371
414
|
if (firstSentence.length <= MAX_OUTPUT_LENGTH) return firstSentence;
|
|
372
|
-
|
|
415
|
+
const boundary = text.lastIndexOf(" ", MAX_OUTPUT_LENGTH);
|
|
416
|
+
const cut = boundary >= MAX_OUTPUT_LENGTH / 2 ? boundary : MAX_OUTPUT_LENGTH;
|
|
417
|
+
const omitted = text.length - cut;
|
|
418
|
+
return `${text.slice(0, cut).trimEnd()}… (${omitted} more chars; full output in thread)`;
|
|
373
419
|
}
|
|
374
420
|
|
|
375
421
|
/**
|
|
@@ -399,7 +445,7 @@ function renderChildDetail(node: TreeNode, indent: string): string[] {
|
|
|
399
445
|
}
|
|
400
446
|
|
|
401
447
|
if (node.status === "completed" && !node.slackReplySent && node.output) {
|
|
402
|
-
lines.push(`${indent}${truncateOutput(node.output)}`);
|
|
448
|
+
lines.push(`${indent}${truncateOutput(markdownToSlack(node.output))}`);
|
|
403
449
|
}
|
|
404
450
|
|
|
405
451
|
return lines;
|
|
@@ -423,7 +469,7 @@ function renderTree(root: TreeNode): string {
|
|
|
423
469
|
lines.push(` Error: ${root.failureReason}`);
|
|
424
470
|
}
|
|
425
471
|
if (root.status === "completed" && !root.slackReplySent && root.output) {
|
|
426
|
-
lines.push(` ${truncateOutput(root.output)}`);
|
|
472
|
+
lines.push(` ${truncateOutput(markdownToSlack(root.output))}`);
|
|
427
473
|
}
|
|
428
474
|
return lines.join("\n");
|
|
429
475
|
}
|
package/src/slack/responses.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Agent, AgentTask } from "../types";
|
|
|
4
4
|
import { getSlackApp } from "./app";
|
|
5
5
|
import {
|
|
6
6
|
buildCancelledBlocks,
|
|
7
|
+
buildCompletedBlockBatches,
|
|
7
8
|
buildCompletedBlocks,
|
|
8
9
|
buildFailedBlocks,
|
|
9
10
|
buildProgressBlocks,
|
|
@@ -74,7 +75,7 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
|
74
75
|
console.log(
|
|
75
76
|
`[Slack] sendTaskResponse: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
|
|
76
77
|
);
|
|
77
|
-
const
|
|
78
|
+
const completionOpts = {
|
|
78
79
|
agentName,
|
|
79
80
|
taskId: task.id,
|
|
80
81
|
body,
|
|
@@ -84,15 +85,21 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
|
|
|
84
85
|
// trailing addendum so links are visible without expanding the card.
|
|
85
86
|
minimal: !!task.slackReplySent,
|
|
86
87
|
trailer: task.slackReplySent ? attachmentsBlock : undefined,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
};
|
|
89
|
+
const blockBatches = buildCompletedBlockBatches(completionOpts);
|
|
90
|
+
for (let i = 0; i < blockBatches.length; i++) {
|
|
91
|
+
await sendWithPersona(client, {
|
|
92
|
+
channel: task.slackChannelId,
|
|
93
|
+
thread_ts: task.slackThreadTs,
|
|
94
|
+
text:
|
|
95
|
+
task.slackReplySent || i > 0
|
|
96
|
+
? `✅ ${agentName} completed${i > 0 ? ` (continued ${i + 1}/${blockBatches.length})` : ""}`
|
|
97
|
+
: body,
|
|
98
|
+
username: getAgentDisplayName(agent),
|
|
99
|
+
icon_emoji: getAgentEmoji(agent),
|
|
100
|
+
blocks: blockBatches[i],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
96
103
|
} else if (task.status === "failed") {
|
|
97
104
|
const reason = task.failureReason || "Unknown error";
|
|
98
105
|
const blocks = buildFailedBlocks({ agentName, taskId: task.id, reason });
|
|
@@ -199,6 +206,7 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
199
206
|
const agentName = agent.name;
|
|
200
207
|
let blocks: unknown[];
|
|
201
208
|
let text: string;
|
|
209
|
+
let completionBlockBatches: unknown[][] | undefined;
|
|
202
210
|
|
|
203
211
|
if (task.status === "completed") {
|
|
204
212
|
const output = task.output || "Task completed.";
|
|
@@ -212,14 +220,16 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
212
220
|
console.log(
|
|
213
221
|
`[Slack] updateToFinal: task=${task.id} slackReplySent=${!!task.slackReplySent} minimal=${!!task.slackReplySent}`,
|
|
214
222
|
);
|
|
215
|
-
|
|
223
|
+
const completionOpts = {
|
|
216
224
|
agentName,
|
|
217
225
|
taskId: task.id,
|
|
218
226
|
body,
|
|
219
227
|
duration,
|
|
220
228
|
minimal: !!task.slackReplySent,
|
|
221
229
|
trailer: task.slackReplySent ? attachmentsBlock : undefined,
|
|
222
|
-
}
|
|
230
|
+
};
|
|
231
|
+
completionBlockBatches = buildCompletedBlockBatches(completionOpts);
|
|
232
|
+
blocks = completionBlockBatches[0] ?? buildCompletedBlocks(completionOpts);
|
|
223
233
|
text = task.slackReplySent ? `✅ ${agentName} completed` : body;
|
|
224
234
|
} else if (task.status === "cancelled") {
|
|
225
235
|
blocks = buildCancelledBlocks({ agentName, taskId: task.id });
|
|
@@ -238,6 +248,19 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
|
|
|
238
248
|
// biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
|
|
239
249
|
blocks: blocks as any,
|
|
240
250
|
});
|
|
251
|
+
|
|
252
|
+
if (completionBlockBatches) {
|
|
253
|
+
for (let i = 1; i < completionBlockBatches.length; i++) {
|
|
254
|
+
await sendWithPersona(app.client, {
|
|
255
|
+
channel: task.slackChannelId,
|
|
256
|
+
thread_ts: task.slackThreadTs ?? messageTs,
|
|
257
|
+
text: `✅ ${agentName} completed (continued ${i + 1}/${completionBlockBatches.length})`,
|
|
258
|
+
username: getAgentDisplayName(agent),
|
|
259
|
+
icon_emoji: getAgentEmoji(agent),
|
|
260
|
+
blocks: completionBlockBatches[i],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
241
264
|
return true;
|
|
242
265
|
} catch (error) {
|
|
243
266
|
console.error(`[Slack] Failed to update task message to final state:`, error);
|
package/src/slack/watcher.ts
CHANGED
|
@@ -696,13 +696,6 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
696
696
|
}
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
-
// Skip tasks tracked in a tree — they're rendered by processTreeMessages()
|
|
700
|
-
// But mark as notified to prevent re-processing if tree is cleaned up
|
|
701
|
-
if (taskToTree.has(task.id)) {
|
|
702
|
-
notifiedCompletions.set(task.id, now);
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
699
|
const completionKey = `completion:${task.id}`;
|
|
707
700
|
|
|
708
701
|
// Skip if already notified or currently sending or sent recently
|
|
@@ -710,6 +703,34 @@ export function startTaskWatcher(intervalMs = 3000): void {
|
|
|
710
703
|
const lastSent = lastSendTime.get(completionKey);
|
|
711
704
|
if (lastSent && now - lastSent < MIN_SEND_INTERVAL) continue;
|
|
712
705
|
|
|
706
|
+
// Tasks tracked in a tree are still rendered by processTreeMessages().
|
|
707
|
+
// Successful tasks without their own slack-reply also get a full
|
|
708
|
+
// threaded completion message so the tree's compact preview is not the
|
|
709
|
+
// only place their output appears.
|
|
710
|
+
if (taskToTree.has(task.id)) {
|
|
711
|
+
if (task.status !== "completed" || task.slackReplySent) {
|
|
712
|
+
notifiedCompletions.set(task.id, now);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
pendingSends.add(completionKey);
|
|
717
|
+
notifiedCompletions.set(task.id, now);
|
|
718
|
+
lastSendTime.set(completionKey, now);
|
|
719
|
+
try {
|
|
720
|
+
await sendTaskResponse(task);
|
|
721
|
+
console.log(
|
|
722
|
+
`[Slack] Sent full tree-tracked completion for task ${task.id.slice(0, 8)}`,
|
|
723
|
+
);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
notifiedCompletions.delete(task.id);
|
|
726
|
+
lastSendTime.delete(completionKey);
|
|
727
|
+
console.error(`[Slack] Failed to send tree-tracked completion:`, error);
|
|
728
|
+
} finally {
|
|
729
|
+
pendingSends.delete(completionKey);
|
|
730
|
+
}
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
|
|
713
734
|
// Mark as pending and notified BEFORE sending
|
|
714
735
|
pendingSends.add(completionKey);
|
|
715
736
|
notifiedCompletions.set(task.id, now);
|
|
@@ -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
|
+
});
|