@desplega.ai/agent-swarm 1.98.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.
@@ -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~, <url|text>
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 splitText(text: string): string[] {
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 splitText(opts.body)) {
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 splitText(opts.trailer)) {
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
- return `${text.slice(0, MAX_OUTPUT_LENGTH)}…`;
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
  }
@@ -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 blocks = buildCompletedBlocks({
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
- await sendWithPersona(client, {
89
- channel: task.slackChannelId,
90
- thread_ts: task.slackThreadTs,
91
- text: task.slackReplySent ? `✅ ${agentName} completed` : body,
92
- username: getAgentDisplayName(agent),
93
- icon_emoji: getAgentEmoji(agent),
94
- blocks,
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
- blocks = buildCompletedBlocks({
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);
@@ -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
+ });