@ricky-stevens/context-guardian 2.1.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +63 -0
  3. package/.github/workflows/ci.yml +66 -0
  4. package/CLAUDE.md +132 -0
  5. package/LICENSE +21 -0
  6. package/README.md +362 -0
  7. package/biome.json +34 -0
  8. package/bun.lock +31 -0
  9. package/hooks/precompact.mjs +73 -0
  10. package/hooks/session-start.mjs +133 -0
  11. package/hooks/stop.mjs +172 -0
  12. package/hooks/submit.mjs +133 -0
  13. package/lib/checkpoint.mjs +258 -0
  14. package/lib/compact-cli.mjs +124 -0
  15. package/lib/compact-output.mjs +350 -0
  16. package/lib/config.mjs +40 -0
  17. package/lib/content.mjs +33 -0
  18. package/lib/diagnostics.mjs +221 -0
  19. package/lib/estimate.mjs +254 -0
  20. package/lib/extract-helpers.mjs +869 -0
  21. package/lib/handoff.mjs +329 -0
  22. package/lib/logger.mjs +34 -0
  23. package/lib/mcp-tools.mjs +200 -0
  24. package/lib/paths.mjs +90 -0
  25. package/lib/stats.mjs +81 -0
  26. package/lib/statusline.mjs +123 -0
  27. package/lib/synthetic-session.mjs +273 -0
  28. package/lib/tokens.mjs +170 -0
  29. package/lib/tool-summary.mjs +399 -0
  30. package/lib/transcript.mjs +939 -0
  31. package/lib/trim.mjs +158 -0
  32. package/package.json +22 -0
  33. package/skills/compact/SKILL.md +20 -0
  34. package/skills/config/SKILL.md +70 -0
  35. package/skills/handoff/SKILL.md +26 -0
  36. package/skills/prune/SKILL.md +20 -0
  37. package/skills/stats/SKILL.md +100 -0
  38. package/sonar-project.properties +12 -0
  39. package/test/checkpoint.test.mjs +171 -0
  40. package/test/compact-cli.test.mjs +230 -0
  41. package/test/compact-output.test.mjs +284 -0
  42. package/test/compaction-e2e.test.mjs +809 -0
  43. package/test/content.test.mjs +86 -0
  44. package/test/diagnostics.test.mjs +188 -0
  45. package/test/edge-cases.test.mjs +543 -0
  46. package/test/estimate.test.mjs +262 -0
  47. package/test/extract-helpers-coverage.test.mjs +333 -0
  48. package/test/extract-helpers.test.mjs +234 -0
  49. package/test/handoff.test.mjs +738 -0
  50. package/test/integration.test.mjs +582 -0
  51. package/test/logger.test.mjs +70 -0
  52. package/test/manual-compaction-test.md +426 -0
  53. package/test/mcp-tools.test.mjs +443 -0
  54. package/test/paths.test.mjs +250 -0
  55. package/test/quick-compaction-test.md +191 -0
  56. package/test/stats.test.mjs +88 -0
  57. package/test/statusline.test.mjs +222 -0
  58. package/test/submit.test.mjs +232 -0
  59. package/test/synthetic-session.test.mjs +600 -0
  60. package/test/tokens.test.mjs +293 -0
  61. package/test/tool-summary.test.mjs +771 -0
  62. package/test/transcript-coverage.test.mjs +369 -0
  63. package/test/transcript.test.mjs +596 -0
  64. package/test/trim.test.mjs +356 -0
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+ import { estimateSavings } from "../lib/estimate.mjs";
7
+
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-estimate-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ /** Write a JSONL transcript file from an array of objects. */
19
+ function writeTranscript(name, lines) {
20
+ const p = path.join(tmpDir, name);
21
+ fs.writeFileSync(p, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
22
+ return p;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers for building transcript lines
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function userText(text) {
30
+ return { type: "user", message: { role: "user", content: text } };
31
+ }
32
+
33
+ function userBlocks(blocks) {
34
+ return { type: "user", message: { role: "user", content: blocks } };
35
+ }
36
+
37
+ function assistantText(text) {
38
+ return {
39
+ type: "assistant",
40
+ message: {
41
+ role: "assistant",
42
+ content: [{ type: "text", text }],
43
+ },
44
+ };
45
+ }
46
+
47
+ function systemMsg(text) {
48
+ return { type: "system", message: { content: text } };
49
+ }
50
+
51
+ function progressMsg(text) {
52
+ return { type: "progress", message: { content: text } };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Tests
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe("estimateSavings", () => {
60
+ it("returns zeros when transcriptPath is null", () => {
61
+ const result = estimateSavings(null, 50000, 200000);
62
+ assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
63
+ });
64
+
65
+ it("returns zeros when transcriptPath does not exist", () => {
66
+ const result = estimateSavings("/nonexistent/path.jsonl", 50000, 200000);
67
+ assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
68
+ });
69
+
70
+ it("returns zeros when transcript is empty", () => {
71
+ const p = path.join(tmpDir, "empty.jsonl");
72
+ fs.writeFileSync(p, "");
73
+ const result = estimateSavings(p, 50000, 200000);
74
+ assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
75
+ });
76
+
77
+ it("system/progress messages are all removable — estimates lower than raw percentage", () => {
78
+ const p = writeTranscript("sys-progress.jsonl", [
79
+ systemMsg("System prompt content here with some length to it"),
80
+ progressMsg("Loading tools..."),
81
+ systemMsg("Another system message with extra content padding"),
82
+ progressMsg("Still loading more tools and resources..."),
83
+ ]);
84
+
85
+ const rawPct = (50000 / 200000) * 100; // 25%
86
+ const result = estimateSavings(p, 50000, 200000);
87
+ assert.ok(result.smartPct <= rawPct);
88
+ assert.ok(result.recentPct <= rawPct);
89
+ assert.equal(result.smartPct, result.recentPct);
90
+ });
91
+
92
+ it("user text + assistant text — both kept, estimates reflect kept content", () => {
93
+ const p = writeTranscript("text-only.jsonl", [
94
+ userText("Please help me refactor the auth module"),
95
+ assistantText(
96
+ "I will refactor the auth module by extracting the token validation into a separate function.",
97
+ ),
98
+ ]);
99
+
100
+ const result = estimateSavings(p, 50000, 200000);
101
+ assert.ok(result.smartPct > 10);
102
+ assert.ok(result.recentPct > 0);
103
+ });
104
+
105
+ it("large tool_result content is mostly removed — low estimate", () => {
106
+ const bigContent = "x".repeat(50000);
107
+ const p = writeTranscript("tool-result.jsonl", [
108
+ userText("Read the config file"),
109
+ assistantText("I will read the config file for you."),
110
+ userBlocks([
111
+ { type: "text", text: "Here is the result" },
112
+ {
113
+ type: "tool_result",
114
+ tool_use_id: "toolu_123",
115
+ content: bigContent,
116
+ },
117
+ ]),
118
+ ]);
119
+
120
+ const rawPct = (80000 / 200000) * 100; // 40%
121
+ const result = estimateSavings(p, 80000, 200000);
122
+ assert.ok(result.smartPct < rawPct);
123
+ });
124
+
125
+ it("baselineOverhead parameter affects the result", () => {
126
+ const bigContent = "y".repeat(20000);
127
+ const p = writeTranscript("overhead.jsonl", [
128
+ userText("Analyse the file"),
129
+ assistantText("I will read and analyse the file."),
130
+ userBlocks([
131
+ { type: "text", text: "Result" },
132
+ {
133
+ type: "tool_result",
134
+ tool_use_id: "toolu_oh1",
135
+ content: bigContent,
136
+ },
137
+ ]),
138
+ assistantText("The file looks good."),
139
+ ]);
140
+
141
+ const withoutOverhead = estimateSavings(p, 80000, 200000, 0);
142
+ const withOverhead = estimateSavings(p, 80000, 200000, 40000);
143
+ assert.notEqual(withoutOverhead.smartPct, withOverhead.smartPct);
144
+ });
145
+
146
+ it("returns zeros when maxTokens is zero or NaN", () => {
147
+ const p = writeTranscript("zero-max.jsonl", [
148
+ userText("hello"),
149
+ assistantText("hi"),
150
+ ]);
151
+ assert.deepStrictEqual(estimateSavings(p, 1000, 0), {
152
+ smartPct: 0,
153
+ recentPct: 0,
154
+ });
155
+ assert.deepStrictEqual(estimateSavings(p, 1000, NaN), {
156
+ smartPct: 0,
157
+ recentPct: 0,
158
+ });
159
+ assert.deepStrictEqual(estimateSavings(p, 1000, -100), {
160
+ smartPct: 0,
161
+ recentPct: 0,
162
+ });
163
+ });
164
+
165
+ it("thinking blocks are categorised as removable", () => {
166
+ const p = writeTranscript("thinking.jsonl", [
167
+ userText("solve this problem"),
168
+ {
169
+ type: "assistant",
170
+ message: {
171
+ role: "assistant",
172
+ content: [
173
+ {
174
+ type: "thinking",
175
+ thinking: "Let me think about this carefully... ".repeat(100),
176
+ },
177
+ { type: "text", text: "The answer is 42." },
178
+ ],
179
+ },
180
+ },
181
+ ]);
182
+
183
+ const rawPct = (50000 / 200000) * 100;
184
+ const result = estimateSavings(p, 50000, 200000);
185
+ assert.ok(result.smartPct < rawPct);
186
+ });
187
+
188
+ it("tool_use blocks are partially kept (summary ratio)", () => {
189
+ const p = writeTranscript("tool-use.jsonl", [
190
+ userText("read the file"),
191
+ {
192
+ type: "assistant",
193
+ message: {
194
+ role: "assistant",
195
+ content: [
196
+ {
197
+ type: "tool_use",
198
+ id: "t1",
199
+ name: "Read",
200
+ input: { file_path: "/some/very/long/path/to/file.js" },
201
+ },
202
+ { type: "text", text: "Here is the file content." },
203
+ ],
204
+ },
205
+ },
206
+ ]);
207
+
208
+ const result = estimateSavings(p, 50000, 200000);
209
+ assert.ok(result.smartPct > 0);
210
+ });
211
+
212
+ it("assistant string content (non-array) is categorised as kept", () => {
213
+ const p = writeTranscript("string-content.jsonl", [
214
+ userText("hello"),
215
+ {
216
+ type: "assistant",
217
+ message: { role: "assistant", content: "Simple string response" },
218
+ },
219
+ ]);
220
+
221
+ const result = estimateSavings(p, 50000, 200000);
222
+ assert.ok(result.smartPct > 0);
223
+ });
224
+
225
+ it("redacted_thinking blocks are categorised as removable", () => {
226
+ const p = writeTranscript("redacted.jsonl", [
227
+ userText("think about this"),
228
+ {
229
+ type: "assistant",
230
+ message: {
231
+ role: "assistant",
232
+ content: [
233
+ { type: "redacted_thinking" },
234
+ { type: "text", text: "Done thinking." },
235
+ ],
236
+ },
237
+ },
238
+ ]);
239
+
240
+ const result = estimateSavings(p, 50000, 200000);
241
+ assert.ok(result.smartPct > 0);
242
+ });
243
+
244
+ it("userExchanges counting — 20 exchanges makes recentPct less than smartPct", () => {
245
+ const padding = "A".repeat(5000);
246
+ const lines = [];
247
+ for (let i = 0; i < 20; i++) {
248
+ lines.push(userText(`User message ${i + 1}: ${padding}`));
249
+ lines.push(assistantText(`Response ${i + 1}: ${padding}`));
250
+ }
251
+ const p = writeTranscript("twenty-exchanges.jsonl", lines);
252
+
253
+ const result = estimateSavings(p, 60000, 200000);
254
+ assert.ok(result.smartPct > 0);
255
+ assert.ok(result.recentPct > 0);
256
+ assert.ok(result.recentPct < result.smartPct);
257
+
258
+ const ratio = result.recentPct / result.smartPct;
259
+ assert.ok(ratio < 0.85);
260
+ assert.ok(ratio > 0.4);
261
+ });
262
+ });
@@ -0,0 +1,333 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ addSectionHeaders,
5
+ generateConversationIndex,
6
+ } from "../lib/extract-helpers.mjs";
7
+
8
+ // Helper: build alternating user/assistant exchanges.
9
+ // generateConversationIndex skips exchanges with no extractable facts or tool work,
10
+ // so we support richUser (entity-laden text) and withTools (edit tool lines) options.
11
+ function buildExchanges(count, opts = {}) {
12
+ const messages = [];
13
+ for (let i = 1; i <= count; i++) {
14
+ if (opts.richUser) {
15
+ messages.push(`**User:** Fix ZEP-${1000 + i} which costs $${10000 + i}`);
16
+ } else {
17
+ messages.push(`**User:** User message ${i}`);
18
+ }
19
+ messages.push(`**Assistant:** Assistant response ${i}`);
20
+ if (opts.withTools) {
21
+ messages.push(`\u2192 Edit \`file${i}.js\``);
22
+ messages.push(`\u2190 success`);
23
+ }
24
+ }
25
+ return messages;
26
+ }
27
+
28
+ describe("generateConversationIndex", () => {
29
+ it("returns empty string for fewer than 10 messages", () => {
30
+ const messages = buildExchanges(2, { withTools: true }); // 8 messages
31
+ const result = generateConversationIndex(messages);
32
+ assert.equal(result, "");
33
+ });
34
+
35
+ it("returns empty string for exactly 9 messages", () => {
36
+ const messages = buildExchanges(2, { withTools: true }); // 8 messages
37
+ messages.push("**User:** One more question");
38
+ assert.equal(messages.length, 9);
39
+ const result = generateConversationIndex(messages);
40
+ assert.equal(result, "");
41
+ });
42
+
43
+ it("returns empty string when no exchanges produce facts or work", () => {
44
+ // 20 messages but all generic — no entities, no tool lines
45
+ const messages = buildExchanges(10);
46
+ const result = generateConversationIndex(messages);
47
+ assert.equal(
48
+ result,
49
+ "",
50
+ "Generic user/assistant pairs without entities or tools should produce empty index",
51
+ );
52
+ });
53
+
54
+ it("generates index entries for exchanges with tool work", () => {
55
+ const messages = buildExchanges(6, { withTools: true }); // 24 messages
56
+ const result = generateConversationIndex(messages);
57
+ assert.notEqual(result, "");
58
+ assert.ok(
59
+ result.includes("## Conversation Index"),
60
+ "Should have index header",
61
+ );
62
+ assert.ok(
63
+ result.includes("Compact reference"),
64
+ "Should have description line",
65
+ );
66
+ assert.ok(result.includes("[1]"), "Should have first exchange entry");
67
+ assert.ok(result.includes("[6]"), "Should have last exchange entry");
68
+ });
69
+
70
+ it("generates index entries for entity-rich user messages without tools", () => {
71
+ const messages = buildExchanges(6, { richUser: true }); // 12 messages, entities in user text
72
+ const result = generateConversationIndex(messages);
73
+ assert.notEqual(result, "");
74
+ assert.ok(result.includes("## Conversation Index"));
75
+ assert.ok(
76
+ result.includes("ZEP-"),
77
+ "Should include ticket IDs from user messages",
78
+ );
79
+ assert.ok(result.includes("$"), "Should include money references");
80
+ });
81
+
82
+ it("includes decisions section when user messages contain decision patterns", () => {
83
+ const messages = [
84
+ "**User:** I chose to use PostgreSQL instead of MySQL",
85
+ "**Assistant:** Great choice.",
86
+ "\u2192 Edit `db.js`",
87
+ "\u2190 success",
88
+ "**User:** I rejected the caching approach",
89
+ "**Assistant:** Understood.",
90
+ "\u2192 Edit `cache.js`",
91
+ "\u2190 success",
92
+ "**User:** I decided to go with REST over GraphQL",
93
+ "**Assistant:** REST it is.",
94
+ "\u2192 Edit `api.js`",
95
+ "\u2190 success",
96
+ ];
97
+ const result = generateConversationIndex(messages);
98
+ assert.notEqual(result, "");
99
+ assert.ok(
100
+ result.includes("**Decisions:**"),
101
+ "Should have decisions section",
102
+ );
103
+ assert.ok(result.includes("chose"), "Should include 'chose' decision");
104
+ assert.ok(
105
+ result.includes("rejected"),
106
+ "Should include 'rejected' decision",
107
+ );
108
+ assert.ok(result.includes("decided"), "Should include 'decided' decision");
109
+ });
110
+
111
+ it("includes error-resolution pairs", () => {
112
+ const messages = [
113
+ "**User:** Run the build",
114
+ "**Assistant:** Running the build now.",
115
+ "\u2192 Bash `npm run build`",
116
+ "\u2190 Error: Module not found lodash",
117
+ "**User:** Fix the missing dependency",
118
+ "**Assistant:** I'll install lodash.",
119
+ "\u2192 Bash `npm install lodash`",
120
+ "\u2190 added 1 package",
121
+ "**User:** Try the build again",
122
+ "**Assistant:** Running build again.",
123
+ "\u2192 Bash `npm run build`",
124
+ "\u2190 Build successful",
125
+ ];
126
+ const result = generateConversationIndex(messages);
127
+ assert.notEqual(result, "");
128
+ assert.ok(
129
+ result.includes("**Errors resolved:**"),
130
+ "Should have error resolution section",
131
+ );
132
+ assert.ok(
133
+ result.includes("Module not found"),
134
+ "Should include the error text",
135
+ );
136
+ assert.ok(result.includes("\u2192"), "Should include resolution arrow");
137
+ });
138
+
139
+ it("truncates long facts to 250 chars and appends entity tags", () => {
140
+ const longText = "A".repeat(300);
141
+ const messages = [
142
+ `**User:** ${longText} and also reference ZEP-4471 and $184,000`,
143
+ "**Assistant:** Understood.",
144
+ "\u2192 Edit `big.js`",
145
+ "\u2190 success",
146
+ "**User:** Continue with port 5433 configuration",
147
+ "**Assistant:** Configuring.",
148
+ "\u2192 Edit `port.js`",
149
+ "\u2190 success",
150
+ "**User:** Update for March 29th deadline",
151
+ "**Assistant:** Noted.",
152
+ "\u2192 Edit `deadline.js`",
153
+ "\u2190 success",
154
+ ];
155
+ const result = generateConversationIndex(messages);
156
+ assert.notEqual(result, "");
157
+ // The long user message should be truncated: 250 chars + "..." + entity tags
158
+ const lines = result.split("\n");
159
+ const longLine = lines.find((l) => l.includes("AAA"));
160
+ assert.ok(longLine, "Should have a line with the truncated A's");
161
+ assert.ok(
162
+ longLine.includes("..."),
163
+ "Truncated line should end with ellipsis",
164
+ );
165
+ // Entity tags should be appended in braces
166
+ assert.ok(longLine.includes("{"), "Should have entity tag block");
167
+ assert.ok(longLine.includes("ZEP-4471"), "Should tag ZEP-4471 entity");
168
+ assert.ok(longLine.includes("$184,000"), "Should tag $184,000 entity");
169
+ // The full 300-char string should NOT appear (truncated at 250)
170
+ assert.ok(
171
+ !longLine.includes("A".repeat(300)),
172
+ "Should not contain full 300-char string",
173
+ );
174
+ });
175
+
176
+ it("entity extraction pulls IDs, money, dates, and ports into index entries", () => {
177
+ const messages = [
178
+ "**User:** Fix ZEP-4471 which involves $184,000 payment on March 29th using port 5433",
179
+ "**Assistant:** I'll handle it.",
180
+ "\u2192 Edit `payment.js`",
181
+ "\u2190 success",
182
+ "**User:** Also check PROJ-9999 for $50,000 budget",
183
+ "**Assistant:** Looking into it.",
184
+ "\u2192 Edit `budget.js`",
185
+ "\u2190 success",
186
+ "**User:** Deploy on port 8080 by January 15th",
187
+ "**Assistant:** Will target that.",
188
+ "\u2192 Edit `deploy.js`",
189
+ "\u2190 success",
190
+ ];
191
+ const result = generateConversationIndex(messages);
192
+ assert.notEqual(result, "");
193
+ // Entities should appear directly in the facts (short enough to not be truncated)
194
+ assert.ok(result.includes("ZEP-4471"), "Should include ticket ID ZEP-4471");
195
+ assert.ok(
196
+ result.includes("$184,000"),
197
+ "Should include money amount $184,000",
198
+ );
199
+ assert.ok(result.includes("port 5433"), "Should include port reference");
200
+ assert.ok(
201
+ result.includes("PROJ-9999"),
202
+ "Should include ticket ID PROJ-9999",
203
+ );
204
+ });
205
+
206
+ it("handles mixed content with tools, decisions, errors, and entities", () => {
207
+ const messages = [
208
+ "**User:** Implement auth for PROJ-2000 with budget $10,000",
209
+ "**Assistant:** Starting auth implementation.",
210
+ "\u2192 Edit `auth.js`",
211
+ "\u2190 success",
212
+ "**User:** I chose JWT over session tokens",
213
+ "**Assistant:** Using JWT approach.",
214
+ "\u2192 Edit `jwt.js`",
215
+ "\u2190 success",
216
+ "**User:** Run tests on port 3000",
217
+ "**Assistant:** Running tests.",
218
+ "\u2192 Bash `npm test`",
219
+ "\u2190 Error: Connection refused on port 3000",
220
+ "**User:** Fix the connection issue",
221
+ "**Assistant:** Fixing the port configuration.",
222
+ "\u2192 Edit `config.js`",
223
+ "\u2190 success",
224
+ "**User:** I rejected using OAuth for now",
225
+ "**Assistant:** Skipping OAuth.",
226
+ "**User:** Deploy by April 1st deadline",
227
+ "**Assistant:** Targeting April 1st.",
228
+ ];
229
+ const result = generateConversationIndex(messages);
230
+ assert.notEqual(result, "");
231
+ assert.ok(
232
+ result.includes("## Conversation Index"),
233
+ "Should have index header",
234
+ );
235
+ assert.ok(
236
+ result.includes("**Decisions:**"),
237
+ "Should have decisions section",
238
+ );
239
+ assert.ok(
240
+ result.includes("**Errors resolved:**"),
241
+ "Should have error resolution section",
242
+ );
243
+ assert.ok(
244
+ result.includes("PROJ-2000"),
245
+ "Should include entity from first exchange",
246
+ );
247
+ });
248
+ });
249
+
250
+ describe("addSectionHeaders", () => {
251
+ it("returns unchanged array for fewer than 20 messages", () => {
252
+ const messages = buildExchanges(5); // 10 messages
253
+ const result = addSectionHeaders(messages);
254
+ assert.deepEqual(result, messages);
255
+ });
256
+
257
+ it("returns unchanged array for exactly 19 messages", () => {
258
+ const messages = buildExchanges(9); // 18 messages
259
+ messages.push("**User:** One more");
260
+ assert.equal(messages.length, 19);
261
+ const result = addSectionHeaders(messages);
262
+ assert.deepEqual(result, messages);
263
+ });
264
+
265
+ it("inserts section headers every 10 exchanges for 25+ exchange sessions", () => {
266
+ const messages = buildExchanges(25); // 50 messages
267
+ const result = addSectionHeaders(messages, 10);
268
+ assert.ok(
269
+ result.length > messages.length,
270
+ "Should have added header elements",
271
+ );
272
+ // Find all section headers
273
+ const headers = result.filter((m) => m.startsWith("### Exchanges"));
274
+ assert.ok(
275
+ headers.length >= 2,
276
+ `Should have at least 2 section headers, got ${headers.length}`,
277
+ );
278
+ // Check header format
279
+ for (const header of headers) {
280
+ assert.match(
281
+ header,
282
+ /^### Exchanges \d+-\d+$/,
283
+ `Header should match format, got: ${header}`,
284
+ );
285
+ }
286
+ });
287
+
288
+ it("first header starts at exchange 1", () => {
289
+ const messages = buildExchanges(25);
290
+ const result = addSectionHeaders(messages, 10);
291
+ const headers = result.filter((m) => m.startsWith("### Exchanges"));
292
+ assert.ok(headers.length > 0, "Should have headers");
293
+ assert.ok(
294
+ headers[0].startsWith("### Exchanges 1-"),
295
+ `First header should start at 1, got: ${headers[0]}`,
296
+ );
297
+ });
298
+
299
+ it("uses default groupSize when not specified", () => {
300
+ const messages = buildExchanges(25); // 50 messages
301
+ const result = addSectionHeaders(messages);
302
+ const headers = result.filter((m) => m.startsWith("### Exchanges"));
303
+ assert.ok(
304
+ headers.length >= 1,
305
+ "Should insert headers with default groupSize",
306
+ );
307
+ });
308
+
309
+ it("handles messages with tool lines between exchanges", () => {
310
+ const messages = buildExchanges(12, { withTools: true }); // 48 messages
311
+ const result = addSectionHeaders(messages, 5);
312
+ const headers = result.filter((m) => m.startsWith("### Exchanges"));
313
+ assert.ok(
314
+ headers.length >= 1,
315
+ `Should have section headers, got ${headers.length}`,
316
+ );
317
+ // All original messages should still be present
318
+ for (const msg of messages) {
319
+ assert.ok(
320
+ result.includes(msg),
321
+ `Original message should be preserved: ${msg.substring(0, 50)}`,
322
+ );
323
+ }
324
+ });
325
+
326
+ it("preserves message order after inserting headers", () => {
327
+ const messages = buildExchanges(15); // 30 messages
328
+ const result = addSectionHeaders(messages, 5);
329
+ // Filter out headers and verify original order is preserved
330
+ const withoutHeaders = result.filter((m) => !m.startsWith("### Exchanges"));
331
+ assert.deepEqual(withoutHeaders, messages);
332
+ });
333
+ });