@semalt-ai/code 1.19.0 → 1.20.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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. package/path +0 -1
@@ -0,0 +1,542 @@
1
+ 'use strict';
2
+
3
+ // File-activity grouping — a SECOND INSTANCE of the web-activity collapser for
4
+ // consecutive pure file reads/lists (read_file / list_dir). read_file and
5
+ // list_dir share ONE group key, so a mixed read/list exploration phase collapses
6
+ // into a single summary instead of fragmenting; EVERY group (read-only,
7
+ // list-only, or mixed) renders the same single "explored ×N" verb. Covers the live
8
+ // flush sites (driven through the REAL createTurnHandler callbacks, exactly as
9
+ // web-activity-ordering.test.js does for web ops), the flush-time THRESHOLD
10
+ // (1–2 individual lines, 3+ collapsed summary), the merged read/list group, the
11
+ // error-breaks-the-group ordering, terminal-flag gating across iterations, the
12
+ // double-flush guard, and replay re-grouping at the replay terminal width.
13
+
14
+ const { test } = require('node:test');
15
+ const assert = require('node:assert');
16
+
17
+ // Stable colour env for byte comparisons (node:test isolates each file's process).
18
+ process.stdout.isTTY = true;
19
+ delete process.env.NO_COLOR;
20
+
21
+ const { stripAnsi } = require('../lib/ui/utils');
22
+ const { createTurnHandler } = require('../lib/commands/chat-turn');
23
+ const { buildToolOperation, serializeOperation } = require('../lib/ui/tool-operation');
24
+ const {
25
+ createFileActivityTracker, formatFileSummaryLine, fileSummaryState,
26
+ isGroupableFileCore, normalizeFileTag, fileGroupKey,
27
+ } = require('../lib/ui/file-activity');
28
+ const { ChatHistory } = require('../lib/ui/chat-history');
29
+ const { createChatSession } = require('../lib/commands/chat-session');
30
+
31
+ const CFG = { diff_max_lines: 50, shell_preview_lines: 5 };
32
+
33
+ // ── Live harness: drive the real createTurnHandler callbacks ──────────────────
34
+ // Records every committed line in ONE ordered log. The file summary / individual
35
+ // file lines commit via writerModule.endActivity (from fileTracker.flush); the
36
+ // answer via chatHistory.finalizeLastMessage; an error body via addMessage.
37
+ function harness(opts) {
38
+ const events = [];
39
+ const writerModule = {
40
+ startActivity() {}, updateActivity() {},
41
+ endActivity(id, line) {
42
+ // A flush may commit several individual lines joined by '\n' (the <3 case).
43
+ for (const raw of String(line == null ? '' : line).split('\n')) {
44
+ if (raw === '') continue;
45
+ events.push({ kind: 'commit', line: stripAnsi(raw) });
46
+ }
47
+ },
48
+ scrollback(line) { events.push({ kind: 'scrollback', line: stripAnsi(String(line)) }); },
49
+ };
50
+ const chatHistory = {
51
+ addMessage(m) {
52
+ if (m && m.isError) events.push({ kind: 'error-body', output: m.output });
53
+ },
54
+ streamToken() {}, clearStreamingContent() {},
55
+ deferToolOutput() {}, commitDeferredDetail() {},
56
+ finalizeLastMessage(content) { if (content && content.trim()) events.push({ kind: 'answer', content }); },
57
+ };
58
+ const statusBar = { update() {}, onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {} };
59
+ const inputField = { on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {} };
60
+
61
+ let scenario = async () => {};
62
+ const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
63
+ await scenario(loopOpts.callbacks);
64
+ return { messages, metrics: { turns: [] }, withheldActions: [] };
65
+ };
66
+
67
+ const ctx = {
68
+ inputField, statusBar, chatHistory, writerModule, runAgentLoop,
69
+ getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
70
+ approxTokens: () => 0,
71
+ resolveCommand: () => null,
72
+ opts: {},
73
+ TAG_REGISTRY: {},
74
+ collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
75
+ activateNavCapture() {}, finalizeListMsg() {},
76
+ createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
77
+ messages: [], currentModel: 'm', debugMode: (opts && opts.debugMode) || false, pendingImages: [],
78
+ chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
79
+ };
80
+
81
+ const handler = createTurnHandler(ctx, {});
82
+ return { events, handler, setScenario: (fn) => { scenario = fn; } };
83
+ }
84
+
85
+ // Simulate one fully-successful groupable file op (one agent iteration's worth).
86
+ function fileOp(cb, tag, path, bytes) {
87
+ cb.onToolStart(tag, path, { id: `${tag}-${path}`, attrs: { path } });
88
+ cb.onToolEnd(tag, 'contents', 5, { id: `${tag}-${path}`, attrs: { path }, meta: { bytes: bytes || 10 }, error: null });
89
+ }
90
+
91
+ const commits = (events) => events.filter((e) => e.kind === 'commit');
92
+ // Matches a collapsed file summary — a single "explored ×N" verb for every group
93
+ // composition (read-only, list-only, or mixed).
94
+ const summaries = (events) => commits(events).filter((e) => /file .* explored ×\d+/.test(e.line));
95
+
96
+ // ───────────────────────────────────────────────────────────────────────────
97
+ // (a) 10 consecutive read_file ops → ONE "✓ file · explored ×10 (…)" summary line.
98
+ // ───────────────────────────────────────────────────────────────────────────
99
+ test('(a) 10 reads collapse to ONE summary; ×10 always present; basenames truncated to width', async () => {
100
+ const prevCols = process.stdout.columns;
101
+ process.stdout.columns = 60; // narrow → force basename truncation
102
+ try {
103
+ const h = harness();
104
+ const files = Array.from({ length: 10 }, (_, i) => `/proj/src/file-${i}-with-a-long-name.js`);
105
+ h.setScenario(async (cb) => {
106
+ cb.onAssistantMessage(''); // one tool-call-only iteration
107
+ for (const f of files) fileOp(cb, 'read', f, 100 + 1);
108
+ cb.onAssistantMessage('Done reading.'); // terminal
109
+ });
110
+ await h.handler('warm up');
111
+
112
+ const s = summaries(h.events);
113
+ assert.strictEqual(s.length, 1, 'exactly one collapsed summary');
114
+ assert.match(s[0].line, /file .* explored ×10 \(/, 'shows the explored verb and the ×10 count');
115
+ assert.ok(s[0].line.includes('…'), 'the basename list is truncated to width');
116
+ assert.match(s[0].line, /×10/, 'the ×10 count survives truncation (it is in the fixed prefix)');
117
+ // Single physical row at the render width.
118
+ assert.ok(s[0].line.length <= 60, `summary fits one 60-col row (got ${s[0].line.length})`);
119
+ // No per-op read lines leaked alongside the summary.
120
+ assert.strictEqual(commits(h.events).filter((e) => /read \//.test(e.line)).length, 0, 'no individual read lines');
121
+ } finally {
122
+ process.stdout.columns = prevCols;
123
+ }
124
+ });
125
+
126
+ // ───────────────────────────────────────────────────────────────────────────
127
+ // (b) 2 read ops → two individual lines (threshold: <3 stays per-op).
128
+ // ───────────────────────────────────────────────────────────────────────────
129
+ test('(b) 2 reads commit as two individual lines, no summary', async () => {
130
+ const h = harness();
131
+ h.setScenario(async (cb) => {
132
+ cb.onAssistantMessage('');
133
+ fileOp(cb, 'read', '/a.js');
134
+ fileOp(cb, 'read', '/b.js');
135
+ cb.onAssistantMessage('done');
136
+ });
137
+ await h.handler('read two');
138
+
139
+ assert.strictEqual(summaries(h.events).length, 0, 'no collapsed summary for a 2-op run');
140
+ const indiv = commits(h.events).filter((e) => /read \//.test(e.line));
141
+ assert.strictEqual(indiv.length, 2, 'two individual per-op result lines');
142
+ assert.match(indiv[0].line, /read \/a\.js/);
143
+ assert.match(indiv[1].line, /read \/b\.js/);
144
+ });
145
+
146
+ // ───────────────────────────────────────────────────────────────────────────
147
+ // (c) reads and lists INTERLEAVED (read, list, read, list, read) → ONE merged
148
+ // summary with the single "explored ×5" verb (was: two separate summaries —
149
+ // CHANGED by the key-merge: read_file + list_dir now share one group).
150
+ // ───────────────────────────────────────────────────────────────────────────
151
+ test('(c) interleaved reads+lists collapse to ONE merged summary with the explored verb', async () => {
152
+ const h = harness();
153
+ h.setScenario(async (cb) => {
154
+ cb.onAssistantMessage('');
155
+ fileOp(cb, 'read', '/a.js');
156
+ fileOp(cb, 'list_dir', '/d0');
157
+ fileOp(cb, 'read', '/b.js');
158
+ fileOp(cb, 'list_dir', '/d1');
159
+ fileOp(cb, 'read', '/c.js');
160
+ cb.onAssistantMessage('done');
161
+ });
162
+ await h.handler('interleaved reads and lists');
163
+
164
+ const s = summaries(h.events);
165
+ assert.strictEqual(s.length, 1, 'one merged summary — read and list share a group now');
166
+ assert.match(s[0].line, /file .* explored ×5 \(/, 'mixed group uses the single "explored ×5" verb');
167
+ assert.doesNotMatch(s[0].line, /read ×|list ×|file ×/, 'no read/list/file verb for the merged group');
168
+ // All five basenames/dirs listed once in the merged summary.
169
+ for (const b of ['a.js', 'd0', 'b.js', 'd1', 'c.js']) {
170
+ assert.ok(s[0].line.includes(b), `merged summary lists ${b}`);
171
+ }
172
+ });
173
+
174
+ // ───────────────────────────────────────────────────────────────────────────
175
+ // (c2) list-only run (5 list_dir, no reads) → "explored ×5" (single verb).
176
+ // ───────────────────────────────────────────────────────────────────────────
177
+ test('(c2) 5 list_dir ops only → "explored ×5" summary (single verb, no list branch)', async () => {
178
+ const h = harness();
179
+ h.setScenario(async (cb) => {
180
+ cb.onAssistantMessage('');
181
+ for (let i = 0; i < 5; i++) fileOp(cb, 'list_dir', `/dir${i}`);
182
+ cb.onAssistantMessage('done');
183
+ });
184
+ await h.handler('five lists');
185
+
186
+ const s = summaries(h.events);
187
+ assert.strictEqual(s.length, 1, 'one summary');
188
+ assert.match(s[0].line, /explored ×5/, 'list-only group uses the single "explored" verb');
189
+ assert.doesNotMatch(s[0].line, /file ×|read ×|list ×/, 'no read/list/file verb for an all-list group');
190
+ });
191
+
192
+ // ───────────────────────────────────────────────────────────────────────────
193
+ // (c3) a mixed read/list run broken by a grep in the middle → TWO groups, split
194
+ // by the grep (a non-file tool still breaks the run; only read↔list merges).
195
+ // ───────────────────────────────────────────────────────────────────────────
196
+ test('(c3) a grep between two mixed read/list runs splits them into two summaries', async () => {
197
+ const h = harness();
198
+ h.setScenario(async (cb) => {
199
+ cb.onAssistantMessage('');
200
+ // group 1: read, list, read → mixed ×3
201
+ fileOp(cb, 'read', '/g1a.js');
202
+ fileOp(cb, 'list_dir', '/g1d');
203
+ fileOp(cb, 'read', '/g1b.js');
204
+ // a grep breaks the run.
205
+ cb.onToolStart('grep', 'TODO', { id: 'grep-1', attrs: { pattern: 'TODO' } });
206
+ cb.onToolEnd('grep', 'match', 7, { id: 'grep-1', attrs: { pattern: 'TODO' }, meta: { matches: 1 }, error: null });
207
+ // group 2: list, read, list → mixed ×3
208
+ fileOp(cb, 'list_dir', '/g2d');
209
+ fileOp(cb, 'read', '/g2a.js');
210
+ fileOp(cb, 'list_dir', '/g2e');
211
+ cb.onAssistantMessage('done');
212
+ });
213
+ await h.handler('mixed, grep, mixed');
214
+
215
+ const s = summaries(h.events);
216
+ assert.strictEqual(s.length, 2, 'the grep splits the run into two merged summaries');
217
+ assert.match(s[0].line, /explored ×3/, 'first mixed group is explored ×3');
218
+ assert.match(s[1].line, /explored ×3/, 'second mixed group is explored ×3');
219
+ const iS0 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
220
+ const iGrep = h.events.findIndex((e) => e.kind === 'commit' && /TODO/.test(e.line));
221
+ assert.ok(iS0 >= 0 && iGrep >= 0 && iS0 < iGrep, 'the first summary lands above the grep line');
222
+ });
223
+
224
+ // ───────────────────────────────────────────────────────────────────────────
225
+ // (c4) 2 mixed ops (1 read + 1 list) → individual per-op lines (threshold <3,
226
+ // unchanged — merging the key does NOT lower the threshold).
227
+ // ───────────────────────────────────────────────────────────────────────────
228
+ test('(c4) 1 read + 1 list → two individual lines, no merged summary (threshold 3)', async () => {
229
+ const h = harness();
230
+ h.setScenario(async (cb) => {
231
+ cb.onAssistantMessage('');
232
+ fileOp(cb, 'read', '/a.js');
233
+ fileOp(cb, 'list_dir', '/d');
234
+ cb.onAssistantMessage('done');
235
+ });
236
+ await h.handler('one read one list');
237
+
238
+ assert.strictEqual(summaries(h.events).length, 0, 'no collapsed summary for a 2-op run');
239
+ const indiv = commits(h.events).filter((e) => /read \/|list .*\/d/.test(e.line));
240
+ const readLine = commits(h.events).find((e) => /read \/a\.js/.test(e.line));
241
+ const listLine = commits(h.events).find((e) => /\/d/.test(e.line) && !/read/.test(e.line));
242
+ assert.ok(readLine, 'the read commits its own per-op line');
243
+ assert.ok(listLine, 'the list commits its own per-op line');
244
+ assert.strictEqual(indiv.length, 2, 'exactly two individual per-op lines');
245
+ });
246
+
247
+ // ───────────────────────────────────────────────────────────────────────────
248
+ // (d) read run interrupted by a non-file tool → group flushes before the tool row.
249
+ // ───────────────────────────────────────────────────────────────────────────
250
+ test('(d) a non-file tool after a read run flushes the summary before its own line', async () => {
251
+ const h = harness();
252
+ h.setScenario(async (cb) => {
253
+ cb.onAssistantMessage('');
254
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
255
+ // A shell op breaks the group: its onToolStart closes the file group first.
256
+ cb.onToolStart('shell', 'ls -la', { id: 'sh-1', attrs: { command: 'ls -la' } });
257
+ cb.onToolEnd('shell', 'Command `ls -la`:\nExit code: 0\nout', 9, { id: 'sh-1', attrs: { command: 'ls -la' }, meta: { exit_code: 0 }, error: null });
258
+ cb.onAssistantMessage('done');
259
+ });
260
+ await h.handler('read then shell');
261
+
262
+ const iSummary = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
263
+ const iShell = h.events.findIndex((e) => e.kind === 'commit' && /ls -la/.test(e.line));
264
+ assert.ok(iSummary >= 0, 'the read summary committed');
265
+ assert.ok(iShell >= 0, 'the shell line committed');
266
+ assert.ok(iSummary < iShell, 'the read summary lands ABOVE the shell line');
267
+ });
268
+
269
+ // ───────────────────────────────────────────────────────────────────────────
270
+ // (e) read run with op #5 erroring → "explored ×4" summary, then standalone error +
271
+ // body, then a fresh group for the subsequent reads.
272
+ // ───────────────────────────────────────────────────────────────────────────
273
+ test('(e) a mid-run error flushes the success-group, renders error standalone, then a new group starts', async () => {
274
+ const h = harness();
275
+ h.setScenario(async (cb) => {
276
+ cb.onAssistantMessage('');
277
+ for (let i = 0; i < 4; i++) fileOp(cb, 'read', `/ok${i}.js`);
278
+ // op #5 errors — must NOT join the group.
279
+ cb.onToolStart('read', '/bad.js', { id: 'read-bad', attrs: { path: '/bad.js' } });
280
+ cb.onToolEnd('read', 'Error: boom', 3, { id: 'read-bad', attrs: { path: '/bad.js' }, meta: null, error: { message: 'boom' } });
281
+ // three more reads → a brand new group.
282
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/more${i}.js`);
283
+ cb.onAssistantMessage('done');
284
+ });
285
+ await h.handler('reads with an error');
286
+
287
+ const s = summaries(h.events);
288
+ assert.strictEqual(s.length, 2, 'the 4 successes and the 3 later successes form two summaries');
289
+ assert.match(s[0].line, /explored ×4/, 'the errored op did NOT join the group → ×4 not ×5');
290
+ assert.match(s[1].line, /explored ×3/, 'a new group started after the error');
291
+
292
+ const iSummary4 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×4/.test(e.line));
293
+ const iErrLine = h.events.findIndex((e) => e.kind === 'commit' && /read \/bad\.js/.test(e.line));
294
+ const iErrBody = h.events.findIndex((e) => e.kind === 'error-body');
295
+ const iSummary3 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
296
+ assert.ok(iSummary4 >= 0 && iErrLine >= 0 && iErrBody >= 0 && iSummary3 >= 0, 'all four landmarks present');
297
+ assert.ok(iSummary4 < iErrLine, 'success summary before the error line (never above the reads it followed)');
298
+ assert.ok(iErrLine < iErrBody, 'error line before its expandable body');
299
+ assert.ok(iErrBody < iSummary3, 'the new group commits after the error');
300
+ });
301
+
302
+ // ───────────────────────────────────────────────────────────────────────────
303
+ // (f) INTENTIONAL BEHAVIOR CHANGE (Option b — "fix: flush activity groups before
304
+ // content-bearing narration for correct ordering").
305
+ //
306
+ // PREVIOUSLY this asserted that content-bearing INTERMEDIATE narration did
307
+ // NOT split the group (terminal-flag gating → one "explored ×4"). That left
308
+ // the narration committed ABOVE a still-open group → the summary later landed
309
+ // BELOW the conclusion it was based on.
310
+ //
311
+ // NEW behavior: content-bearing intermediate narration flushes the open group
312
+ // FIRST, so a chatty multi-read run FRAGMENTS into correctly-ordered
313
+ // sub-groups (each "explored ×N" above its narration). Silent runs (empty
314
+ // interim narration) still collapse fully — see narration-ordering.test.js.
315
+ // Three reads per fragment so each crosses the ≥3 summary threshold.
316
+ // ───────────────────────────────────────────────────────────────────────────
317
+ test('(f) content-bearing intermediate narration FRAGMENTS a multi-iteration read run (correctly ordered)', async () => {
318
+ const h = harness();
319
+ h.setScenario(async (cb) => {
320
+ // iter 1: three reads, then a NON-empty NON-terminal narration → flushes #1.
321
+ cb.onAssistantMessage('');
322
+ fileOp(cb, 'read', '/i1a.js');
323
+ fileOp(cb, 'read', '/i1b.js');
324
+ fileOp(cb, 'read', '/i1c.js');
325
+ cb.onAssistantMessage('Let me read a couple more files.', { terminal: false });
326
+ // iter 2: three more reads, then the terminal answer → flushes #2.
327
+ fileOp(cb, 'read', '/i2a.js');
328
+ fileOp(cb, 'read', '/i2b.js');
329
+ fileOp(cb, 'read', '/i2c.js');
330
+ cb.onAssistantMessage('All read.', { terminal: true });
331
+ });
332
+ await h.handler('multi-iteration reads with interim narration');
333
+
334
+ const s = summaries(h.events);
335
+ assert.strictEqual(s.length, 2, 'content-bearing interim narration split the run into TWO summaries');
336
+ assert.ok(s.every((e) => /explored ×3/.test(e.line)), 'each fragment is explored ×3');
337
+
338
+ // Ordering: fragment #1 above the interim narration, fragment #2 below it.
339
+ const iSum1 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
340
+ const iNarr1 = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'Let me read a couple more files.');
341
+ const iSum2 = h.events.findIndex((e, idx) => idx > iSum1 && e.kind === 'commit' && /explored ×3/.test(e.line));
342
+ assert.ok(iSum1 < iNarr1, 'fragment #1 commits ABOVE the interim narration');
343
+ assert.ok(iNarr1 < iSum2, 'fragment #2 commits below the interim narration');
344
+ });
345
+
346
+ // ───────────────────────────────────────────────────────────────────────────
347
+ // (g) replay/resume re-groups to the same summary at the REPLAY width; a grouped
348
+ // run replayed narrower re-truncates; the >=3 threshold is applied on replay.
349
+ // ───────────────────────────────────────────────────────────────────────────
350
+
351
+ // Drive the REAL tracker as the live oracle at a fixed width.
352
+ function liveFileSummary(ops, cols) {
353
+ const prev = process.stdout.columns;
354
+ process.stdout.columns = cols;
355
+ try {
356
+ const lines = [];
357
+ const tracker = createFileActivityTracker({ writerModule: {
358
+ startActivity: () => {}, updateActivity: () => {},
359
+ endActivity: (_id, line) => lines.push(line),
360
+ } });
361
+ for (const op of ops) { tracker.start(op.tag, op.target); tracker.end(op); }
362
+ tracker.flush();
363
+ return lines;
364
+ } finally {
365
+ process.stdout.columns = prev;
366
+ }
367
+ }
368
+
369
+ function replayCommits(loadedMessages, cols) {
370
+ const prev = process.stdout.columns;
371
+ process.stdout.columns = cols;
372
+ try {
373
+ const ch = new ChatHistory();
374
+ const out = [];
375
+ ch._commit = (t) => out.push(t);
376
+ const session = createChatSession({ chatHistory: ch, getConfig: () => CFG });
377
+ session.displayLoadedMessages(loadedMessages);
378
+ return out;
379
+ } finally {
380
+ process.stdout.columns = prev;
381
+ }
382
+ }
383
+
384
+ const fileLineOf = (commitsArr) => commitsArr
385
+ .map((c) => stripAnsi(c))
386
+ .filter((c) => /file .* explored ×\d+/.test(c));
387
+
388
+ test('(g) replay re-groups to the same summary at the replay width; narrower re-truncates; ≥3 threshold applied', () => {
389
+ const files = Array.from({ length: 6 }, (_, i) => `/proj/module-${i}/index-file-${i}.js`);
390
+ const ops = files.map((f) => buildToolOperation({ id: `r${f}`, tag: 'read', arg: f, attrs: { path: f }, status: 'ok', durationMs: 5, meta: { bytes: 200 } }));
391
+
392
+ // Persist exactly as the live path does — one native {role:'tool'} message per op
393
+ // carrying the normal serialized core (NO storage format change).
394
+ const loaded = ops.map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
395
+ .concat([{ role: 'assistant', content: 'done' }]);
396
+
397
+ // Same width: replay byte-identical to the live committed summary.
398
+ const oracle200 = liveFileSummary(ops, 200);
399
+ assert.strictEqual(oracle200.length, 1, 'live commits one summary for 6 reads');
400
+ const replay200 = fileLineOf(replayCommits(loaded, 200));
401
+ assert.strictEqual(replay200.length, 1, 'replay commits exactly one file summary');
402
+ assert.strictEqual(replay200[0], stripAnsi(oracle200[0]), 'replay summary is byte-identical to the live one at the same width');
403
+
404
+ // Narrower terminal: re-truncates at 80 cols, but the ×6 count still shows.
405
+ const replay80 = fileLineOf(replayCommits(loaded, 80));
406
+ assert.strictEqual(replay80.length, 1, 'one summary at 80 cols too');
407
+ assert.match(replay80[0], /×6/, 'the ×6 count survives the narrower re-truncation');
408
+ assert.ok(replay80[0].length <= 80, 'fits one 80-col row');
409
+ assert.notStrictEqual(replay80[0], replay200[0], 'the 80-col render re-truncates differently from the 200-col one');
410
+
411
+ // Threshold on replay: a 2-op run replays as individual lines, no summary.
412
+ const twoLoaded = ops.slice(0, 2).map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
413
+ .concat([{ role: 'assistant', content: 'done' }]);
414
+ const replayTwo = replayCommits(twoLoaded, 200);
415
+ assert.strictEqual(fileLineOf(replayTwo).length, 0, 'a 2-op run does NOT collapse on replay');
416
+ assert.strictEqual(replayTwo.filter((c) => /read \//.test(stripAnsi(c))).length, 2, 'it replays as two individual read lines');
417
+ });
418
+
419
+ // ───────────────────────────────────────────────────────────────────────────
420
+ // (g2) replay of a MIXED read/list run re-groups into the SAME single merged
421
+ // summary (single "explored ×N" verb), byte-identical to the live oracle.
422
+ // ───────────────────────────────────────────────────────────────────────────
423
+ test('(g2) replay of a mixed read/list run → identical merged summary at the replay width', () => {
424
+ // read, list, read, list, read — interleaved, persisted as native cores.
425
+ const seq = [
426
+ { tag: 'read', path: '/proj/src/alpha-module.js' },
427
+ { tag: 'list_dir', path: '/proj/src/components' },
428
+ { tag: 'read', path: '/proj/src/beta-module.js' },
429
+ { tag: 'list_dir', path: '/proj/src/utils' },
430
+ { tag: 'read', path: '/proj/src/gamma-module.js' },
431
+ ];
432
+ const ops = seq.map((o, i) => buildToolOperation({
433
+ id: `m${i}`, tag: o.tag, arg: o.path, attrs: { path: o.path },
434
+ status: 'ok', durationMs: 5, meta: o.tag === 'read' ? { bytes: 200 } : { entries: 3 },
435
+ }));
436
+ const loaded = ops.map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
437
+ .concat([{ role: 'assistant', content: 'done' }]);
438
+
439
+ const mixedLineOf = (commitsArr) => commitsArr
440
+ .map((c) => stripAnsi(c))
441
+ .filter((c) => /file .* explored ×\d+/.test(c));
442
+
443
+ const oracle = liveFileSummary(ops, 200);
444
+ assert.strictEqual(oracle.length, 1, 'live commits one merged summary for the mixed run');
445
+ assert.match(stripAnsi(oracle[0]), /explored ×5/, 'live oracle uses the single explored verb for the mixed run');
446
+
447
+ const replay = mixedLineOf(replayCommits(loaded, 200));
448
+ assert.strictEqual(replay.length, 1, 'replay commits exactly one merged file summary');
449
+ assert.strictEqual(replay[0], stripAnsi(oracle[0]), 'replay merged summary is byte-identical to the live one');
450
+ });
451
+
452
+ // ───────────────────────────────────────────────────────────────────────────
453
+ // (h) double-flush guard — the boundary flush + the turn-end finally must commit
454
+ // a group EXACTLY ONCE (endActivity called once per group).
455
+ // ───────────────────────────────────────────────────────────────────────────
456
+ test('(h) double-flush guard: a group commits via endActivity exactly once', () => {
457
+ let endCalls = 0;
458
+ const tracker = createFileActivityTracker({ writerModule: {
459
+ startActivity: () => {}, updateActivity: () => {},
460
+ endActivity: () => { endCalls++; },
461
+ } });
462
+ const op = buildToolOperation({ id: 'r', tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'ok', durationMs: 5, meta: { bytes: 10 } });
463
+ tracker.start('read', '/a.js');
464
+ tracker.end(op);
465
+ assert.ok(tracker.isOpen(), 'group open after one op');
466
+ tracker.flush(); // boundary flush
467
+ assert.strictEqual(tracker.isOpen(), false, 'closed after flush');
468
+ tracker.flush(); // finally flush — must be a no-op
469
+ tracker.flush();
470
+ assert.strictEqual(endCalls, 1, 'endActivity called exactly once despite three flush() calls');
471
+ });
472
+
473
+ // ───────────────────────────────────────────────────────────────────────────
474
+ // (i) the web tracker is UNAFFECTED — a web run interleaved with a file run in
475
+ // one turn still produces its own web summary, untouched.
476
+ // ───────────────────────────────────────────────────────────────────────────
477
+ test('(i) the web tracker is unaffected: a web run alongside a file run still yields a web summary', async () => {
478
+ const h = harness();
479
+ h.setScenario(async (cb) => {
480
+ cb.onAssistantMessage('');
481
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
482
+ // A web op breaks the file group and opens a web group.
483
+ cb.onToolStart('http_get', 'https://x.example', { id: 'g1', attrs: { url: 'https://x.example' } });
484
+ cb.onToolEnd('http_get', {}, 120, { id: 'g1', attrs: { url: 'https://x.example' }, meta: { status_code: 200, bytes: 1000 }, error: null });
485
+ cb.onAssistantMessage('done');
486
+ });
487
+ await h.handler('reads then a fetch');
488
+
489
+ const fileS = summaries(h.events);
490
+ assert.strictEqual(fileS.length, 1, 'one file summary');
491
+ assert.match(fileS[0].line, /explored ×3/);
492
+ const webS = commits(h.events).filter((e) => / web /.test(e.line) && /source/.test(e.line));
493
+ assert.strictEqual(webS.length, 1, 'the web tracker still commits its own summary, unaffected');
494
+ const iFile = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
495
+ const iWeb = h.events.findIndex((e) => e.kind === 'commit' && / web /.test(e.line) && /source/.test(e.line));
496
+ assert.ok(iFile < iWeb, 'the file summary lands above the web summary it preceded');
497
+ });
498
+
499
+ // ───────────────────────────────────────────────────────────────────────────
500
+ // Pure-function spot checks: core/key predicates the replay path depends on.
501
+ // ───────────────────────────────────────────────────────────────────────────
502
+ test('isGroupableFileCore / normalizeFileTag / fileSummaryState predicates', () => {
503
+ const readCore = serializeOperation(buildToolOperation({ tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'ok' }));
504
+ const listCore = serializeOperation(buildToolOperation({ tag: 'list_dir', arg: '/d', attrs: { path: '/d' }, status: 'ok' }));
505
+ const shellCore = serializeOperation(buildToolOperation({ tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, status: 'ok' }));
506
+ const errCore = serializeOperation(buildToolOperation({ tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'error', error: { message: 'x' } }));
507
+
508
+ assert.ok(isGroupableFileCore(readCore), 'a successful read core is groupable');
509
+ assert.ok(isGroupableFileCore(listCore), 'a successful list_dir core is groupable');
510
+ assert.ok(!isGroupableFileCore(shellCore), 'a shell core is not groupable');
511
+ assert.ok(!isGroupableFileCore(errCore), 'an errored read core is not groupable');
512
+ assert.ok(!isGroupableFileCore({ v: 1, kind: 'web', tag: 'http_get' }), 'a web core is not a file core');
513
+ assert.ok(!isGroupableFileCore(null), 'null is tolerated');
514
+
515
+ // read_file and list_dir normalize to DISTINCT tags …
516
+ assert.notStrictEqual(normalizeFileTag(readCore.tag), normalizeFileTag(listCore.tag));
517
+ assert.strictEqual(normalizeFileTag('read'), 'read_file');
518
+ assert.strictEqual(normalizeFileTag('list_dir'), 'list_dir');
519
+ // … but they share ONE group KEY, so a read↔list switch never flushes.
520
+ assert.strictEqual(fileGroupKey('read'), fileGroupKey('list_dir'));
521
+ assert.strictEqual(fileGroupKey('read_file'), fileGroupKey('list_dir'));
522
+
523
+ // Every group composition → the SAME single "explored" / "exploring…" verb,
524
+ // regardless of read-only, list-only, or mixed.
525
+ const st = fileSummaryState([readCore, readCore]);
526
+ assert.strictEqual(st.verb, 'explored');
527
+ assert.strictEqual(st.gerund, 'exploring…');
528
+ assert.strictEqual(st.count, 2);
529
+ assert.deepStrictEqual(st.basenames, ['a.js', 'a.js']);
530
+
531
+ // List-only group → still "explored".
532
+ const stList = fileSummaryState([listCore, listCore]);
533
+ assert.strictEqual(stList.verb, 'explored');
534
+ assert.strictEqual(stList.gerund, 'exploring…');
535
+
536
+ // Mixed group → still "explored" (no composition branching).
537
+ const stMixed = fileSummaryState([readCore, listCore]);
538
+ assert.strictEqual(stMixed.verb, 'explored');
539
+ assert.strictEqual(stMixed.gerund, 'exploring…');
540
+ assert.strictEqual(stMixed.count, 2);
541
+ assert.deepStrictEqual(stMixed.basenames, ['a.js', 'd']);
542
+ });