@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,302 @@
1
+ 'use strict';
2
+
3
+ // Permission-prompt flush — open file/web activity groups must be committed to
4
+ // scrollback when a permission-gated (effectful) tool triggers a prompt, NOT
5
+ // left rendering LIVE in the writer's activity region beside the modal.
6
+ //
7
+ // Root cause this guards: the agent loop asks permission BEFORE onToolStart
8
+ // (agent.js), so onToolStart's "flush the other group before this non-groupable
9
+ // op" step is sequenced AFTER the modal and cannot fire while it is open. The fix
10
+ // adds an unconditional flush of both trackers at the TOP of the onPermissionAsk
11
+ // handler (chat-turn.js). This is safe because groupable tools (read_file /
12
+ // list_dir) are read-only with a NULL permission descriptor, so onPermissionAsk
13
+ // never fires for them — by the time it fires the prompting tool is non-groupable.
14
+ //
15
+ // Tests drive the REAL createTurnHandler callbacks (same harness shape as
16
+ // file-activity.test.js / web-activity-ordering.test.js), simulating the
17
+ // loop's onPermissionAsk → (grant ⇒ onToolStart/onToolEnd | deny ⇒ nothing)
18
+ // sequence by hand.
19
+
20
+ const { test } = require('node:test');
21
+ const assert = require('node:assert');
22
+
23
+ // Stable colour env for byte comparisons (node:test isolates each file's process).
24
+ process.stdout.isTTY = true;
25
+ delete process.env.NO_COLOR;
26
+
27
+ const { stripAnsi } = require('../lib/ui/utils');
28
+ const { createTurnHandler } = require('../lib/commands/chat-turn');
29
+ const { TOOL_REGISTRY } = require('../lib/tool_registry');
30
+
31
+ // ── Live harness: drive the real createTurnHandler callbacks ──────────────────
32
+ // Mirrors file-activity.test.js's harness. Records every committed line in one
33
+ // ordered log so we can assert flush ORDERING (group above the prompting tool).
34
+ function harness(opts) {
35
+ const events = [];
36
+ const writerModule = {
37
+ startActivity() {}, updateActivity() {},
38
+ endActivity(id, line) {
39
+ for (const raw of String(line == null ? '' : line).split('\n')) {
40
+ if (raw === '') continue;
41
+ events.push({ kind: 'commit', line: stripAnsi(raw) });
42
+ }
43
+ },
44
+ scrollback(line) { events.push({ kind: 'scrollback', line: stripAnsi(String(line)) }); },
45
+ };
46
+ const chatHistory = {
47
+ addMessage(m) { if (m && m.isError) events.push({ kind: 'error-body', output: m.output }); },
48
+ streamToken() {}, clearStreamingContent() {},
49
+ deferToolOutput() {}, commitDeferredDetail() {},
50
+ finalizeLastMessage(content) { if (content && content.trim()) events.push({ kind: 'answer', content }); },
51
+ };
52
+ const statusBar = { update() {}, onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {} };
53
+ const inputField = { on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {} };
54
+
55
+ let scenario = async () => {};
56
+ const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
57
+ await scenario(loopOpts.callbacks);
58
+ return { messages, metrics: { turns: [] }, withheldActions: [] };
59
+ };
60
+
61
+ const ctx = {
62
+ inputField, statusBar, chatHistory, writerModule, runAgentLoop,
63
+ getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
64
+ approxTokens: () => 0,
65
+ resolveCommand: () => null,
66
+ opts: {},
67
+ TAG_REGISTRY: {},
68
+ collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
69
+ activateNavCapture() {}, finalizeListMsg() {},
70
+ createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
71
+ messages: [], currentModel: 'm', debugMode: (opts && opts.debugMode) || false, pendingImages: [],
72
+ chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
73
+ };
74
+
75
+ const handler = createTurnHandler(ctx, {});
76
+ return { events, handler, setScenario: (fn) => { scenario = fn; } };
77
+ }
78
+
79
+ // One fully-successful groupable file op (read / list_dir).
80
+ function fileOp(cb, tag, path, bytes) {
81
+ cb.onToolStart(tag, path, { id: `${tag}-${path}`, attrs: { path } });
82
+ cb.onToolEnd(tag, 'contents', 5, { id: `${tag}-${path}`, attrs: { path }, meta: { bytes: bytes || 10 }, error: null });
83
+ }
84
+
85
+ // One fully-successful web op (http_get) — leaves the web group OPEN (it only
86
+ // flushes on a non-web tool start, terminal narration, or turn end).
87
+ function webOp(cb, url) {
88
+ cb.onToolStart('http_get', url, { id: `g-${url}`, attrs: { url } });
89
+ cb.onToolEnd('http_get', {}, 120, { id: `g-${url}`, attrs: { url }, meta: { status_code: 200, bytes: 1000 }, error: null });
90
+ }
91
+
92
+ const commits = (events) => events.filter((e) => e.kind === 'commit');
93
+ const fileSummaries = (events) => commits(events).filter((e) => /file .* explored ×\d+/.test(e.line));
94
+ const webSummaries = (events) => commits(events).filter((e) => / web /.test(e.line) && /source/.test(e.line));
95
+
96
+ // ───────────────────────────────────────────────────────────────────────────
97
+ // (a) 2-read group (below threshold) + permission-gated write_file → the group
98
+ // flushes as TWO individual lines at onPermissionAsk, BEFORE the prompt; no
99
+ // stale live group remains during the modal. (grant path)
100
+ // ───────────────────────────────────────────────────────────────────────────
101
+ test('(a) <3 file group flushes as individual lines at onPermissionAsk, above the prompting tool', async () => {
102
+ const h = harness();
103
+ let commitsAtAsk = -1;
104
+ h.setScenario(async (cb) => {
105
+ cb.onAssistantMessage('');
106
+ fileOp(cb, 'read', '/a.js');
107
+ fileOp(cb, 'read', '/b.js');
108
+ // Effectful tool triggers a permission prompt — fires BEFORE onToolStart.
109
+ cb.onPermissionAsk('write_file', '/out.js');
110
+ commitsAtAsk = commits(h.events).length; // snapshot at the ask
111
+ // Grant → the tool now starts and ends.
112
+ cb.onToolStart('write_file', '/out.js', { id: 'w1', attrs: { path: '/out.js' } });
113
+ cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/out.js' }, meta: { bytes: 3 }, error: null });
114
+ cb.onAssistantMessage('done');
115
+ });
116
+ await h.handler('two reads then a write');
117
+
118
+ // The two reads were committed at the moment the prompt opened — not stranded.
119
+ assert.strictEqual(commitsAtAsk, 2, 'both read lines committed AT onPermissionAsk, before the modal');
120
+ assert.strictEqual(fileSummaries(h.events).length, 0, 'a 2-op group stays individual lines (no summary)');
121
+ const reads = commits(h.events).filter((e) => /read \//.test(e.line));
122
+ assert.strictEqual(reads.length, 2, 'two individual read lines');
123
+ // Ordering: the read lines land ABOVE the write_file line.
124
+ const iLastRead = h.events.map((e) => e).reduce((acc, e, i) => (e.kind === 'commit' && /read \//.test(e.line) ? i : acc), -1);
125
+ const iWrite = h.events.findIndex((e) => e.kind === 'commit' && /out\.js/.test(e.line) && !/read/.test(e.line));
126
+ assert.ok(iLastRead >= 0 && iWrite >= 0 && iLastRead < iWrite, 'read group commits ABOVE the write_file row');
127
+ });
128
+
129
+ // ───────────────────────────────────────────────────────────────────────────
130
+ // (b) ≥3-read group + permission-gated write_file → the group flushes as ONE
131
+ // summary at onPermissionAsk, BEFORE the prompt, above the tool row.
132
+ // ───────────────────────────────────────────────────────────────────────────
133
+ test('(b) ≥3 file group flushes as a summary at onPermissionAsk, above the prompting tool', async () => {
134
+ const h = harness();
135
+ let summariesAtAsk = -1;
136
+ h.setScenario(async (cb) => {
137
+ cb.onAssistantMessage('');
138
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
139
+ cb.onPermissionAsk('write_file', '/out.js');
140
+ summariesAtAsk = fileSummaries(h.events).length; // snapshot at the ask
141
+ cb.onToolStart('write_file', '/out.js', { id: 'w1', attrs: { path: '/out.js' } });
142
+ cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/out.js' }, meta: { bytes: 3 }, error: null });
143
+ cb.onAssistantMessage('done');
144
+ });
145
+ await h.handler('three reads then a write');
146
+
147
+ assert.strictEqual(summariesAtAsk, 1, 'the summary committed AT onPermissionAsk');
148
+ const s = fileSummaries(h.events);
149
+ assert.strictEqual(s.length, 1, 'exactly one summary overall');
150
+ assert.match(s[0].line, /explored ×3/, 'collapsed explored ×3 summary');
151
+ const iSummary = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
152
+ const iWrite = h.events.findIndex((e) => e.kind === 'commit' && /out\.js/.test(e.line) && !/read/.test(e.line));
153
+ assert.ok(iSummary >= 0 && iWrite >= 0 && iSummary < iWrite, 'summary lands ABOVE the write_file row');
154
+ });
155
+
156
+ // ───────────────────────────────────────────────────────────────────────────
157
+ // (c) open WEB group + permission-gated write_file → the web group flushes at
158
+ // onPermissionAsk (the IDENTICAL latent gap on the web tracker).
159
+ // ───────────────────────────────────────────────────────────────────────────
160
+ test('(c) open web group flushes at onPermissionAsk, above the prompting tool', async () => {
161
+ const h = harness();
162
+ let webAtAsk = -1;
163
+ h.setScenario(async (cb) => {
164
+ cb.onAssistantMessage('');
165
+ webOp(cb, 'https://x.example'); // web group left OPEN
166
+ cb.onPermissionAsk('write_file', '/out.js');
167
+ webAtAsk = webSummaries(h.events).length; // snapshot at the ask
168
+ cb.onToolStart('write_file', '/out.js', { id: 'w1', attrs: { path: '/out.js' } });
169
+ cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/out.js' }, meta: { bytes: 3 }, error: null });
170
+ cb.onAssistantMessage('done');
171
+ });
172
+ await h.handler('a fetch then a write');
173
+
174
+ assert.strictEqual(webAtAsk, 1, 'the web summary committed AT onPermissionAsk (latent web gap fixed)');
175
+ const w = webSummaries(h.events);
176
+ assert.strictEqual(w.length, 1, 'exactly one web summary');
177
+ const iWeb = h.events.findIndex((e) => e.kind === 'commit' && / web /.test(e.line) && /source/.test(e.line));
178
+ const iWrite = h.events.findIndex((e) => e.kind === 'commit' && /out\.js/.test(e.line) && !/web/.test(e.line));
179
+ assert.ok(iWeb >= 0 && iWrite >= 0 && iWeb < iWrite, 'web summary lands ABOVE the write_file row');
180
+ });
181
+
182
+ // ───────────────────────────────────────────────────────────────────────────
183
+ // (d) DENIAL path — onToolStart never runs. The group must still be flushed at
184
+ // onPermissionAsk, not stranded live until the turn-end finally.
185
+ // ───────────────────────────────────────────────────────────────────────────
186
+ test('(d) denial path: the group is flushed at onPermissionAsk, not stranded until the finally', async () => {
187
+ const h = harness();
188
+ let commitsAtAsk = -1;
189
+ h.setScenario(async (cb) => {
190
+ cb.onAssistantMessage('');
191
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
192
+ cb.onPermissionAsk('write_file', '/out.js');
193
+ commitsAtAsk = fileSummaries(h.events).length; // snapshot at the ask
194
+ // DENY: agent.js breaks the loop — NO onToolStart, NO onToolEnd for the tool.
195
+ cb.onAssistantMessage('I was denied, stopping.');
196
+ });
197
+ await h.handler('three reads then a denied write');
198
+
199
+ assert.strictEqual(commitsAtAsk, 1, 'the read group was committed AT onPermissionAsk, before deny — not stranded');
200
+ // And there is exactly one summary in total (the finally flush is a no-op).
201
+ assert.strictEqual(fileSummaries(h.events).length, 1, 'still exactly one summary after the finally');
202
+ });
203
+
204
+ // ───────────────────────────────────────────────────────────────────────────
205
+ // (e) DOUBLE-FLUSH guard — onPermissionAsk flush, then the post-grant onToolStart
206
+ // flush, then the turn-end finally flush all call flush(); the group must
207
+ // commit EXACTLY ONCE (idempotent isOpen()/groupId===null guard).
208
+ // ───────────────────────────────────────────────────────────────────────────
209
+ test('(e) double-flush guard: onPermissionAsk + onToolStart + finally → exactly one commit', async () => {
210
+ const h = harness();
211
+ h.setScenario(async (cb) => {
212
+ cb.onAssistantMessage('');
213
+ for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`); // ≥3 → one summary line
214
+ cb.onPermissionAsk('write_file', '/out.js'); // flush #1 (commits)
215
+ cb.onToolStart('write_file', '/out.js', { id: 'w1', attrs: { path: '/out.js' } }); // flush #2 (no-op)
216
+ cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/out.js' }, meta: { bytes: 3 }, error: null });
217
+ cb.onAssistantMessage('done'); // finally flush (no-op)
218
+ });
219
+ await h.handler('idempotent double flush');
220
+
221
+ const s = fileSummaries(h.events);
222
+ assert.strictEqual(s.length, 1, 'the group committed EXACTLY once despite three flush() calls');
223
+ assert.match(s[0].line, /explored ×3/);
224
+ });
225
+
226
+ // ───────────────────────────────────────────────────────────────────────────
227
+ // (f) INTENTIONAL BEHAVIOR CHANGE (Option b — "fix: flush activity groups before
228
+ // content-bearing narration for correct ordering").
229
+ //
230
+ // PREVIOUSLY this test asserted that content-bearing INTERMEDIATE narration
231
+ // ("Reading a couple more.") did NOT split the group, collapsing all four
232
+ // reads into one "explored ×4" summary. That ordering was chronologically
233
+ // WRONG: the narration committed to scrollback ABOVE a still-open group, so
234
+ // the group's summary later landed BELOW the conclusion it was based on.
235
+ //
236
+ // NEW behavior: any content-bearing intermediate narration flushes the open
237
+ // group FIRST, so each sub-group commits ABOVE its narration. A chatty
238
+ // multi-read run therefore FRAGMENTS into correctly-ordered sub-groups
239
+ // ("explored ×3" / narration / "explored ×3") instead of one "explored ×6".
240
+ // This is the deliberate Option-(b) tradeoff — each fragment is chronologically
241
+ // truthful. (Silent runs with empty interim narration STILL fully collapse —
242
+ // see narration-ordering.test.js case (b)/(g).)
243
+ //
244
+ // Uses 3 reads per fragment so each crosses GROUP_THRESHOLD and emits a
245
+ // summary line (a <3 fragment would render individual per-op lines instead).
246
+ // ───────────────────────────────────────────────────────────────────────────
247
+ test('(f) content-bearing interim narration FRAGMENTS the read run into correctly-ordered sub-groups', async () => {
248
+ const h = harness();
249
+ h.setScenario(async (cb) => {
250
+ cb.onAssistantMessage(''); // empty pre-tool narration — must NOT flush
251
+ fileOp(cb, 'read', '/i1a.js');
252
+ fileOp(cb, 'read', '/i1b.js');
253
+ fileOp(cb, 'read', '/i1c.js');
254
+ cb.onAssistantMessage('Reading a couple more.', { terminal: false }); // content-bearing → FLUSHES group #1
255
+ fileOp(cb, 'read', '/i2a.js');
256
+ fileOp(cb, 'read', '/i2b.js');
257
+ fileOp(cb, 'read', '/i2c.js');
258
+ cb.onAssistantMessage('All read.', { terminal: true }); // terminal → flushes group #2
259
+ // onPermissionAsk is intentionally never called for this read-only run.
260
+ });
261
+ await h.handler('multi-iteration reads, content-bearing interim narration');
262
+
263
+ const s = fileSummaries(h.events);
264
+ assert.strictEqual(s.length, 2, 'content-bearing interim narration split the run into TWO summaries');
265
+ assert.match(s[0].line, /explored ×3/, 'first fragment: the three reads before the interim narration');
266
+ assert.match(s[1].line, /explored ×3/, 'second fragment: the three reads after it');
267
+
268
+ // Ordering: each summary lands ABOVE its narration (the Option-(b) guarantee).
269
+ const iSum1 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
270
+ const iNarr1 = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'Reading a couple more.');
271
+ const iSum2 = h.events.findIndex((e, idx) => idx > iSum1 && e.kind === 'commit' && /explored ×3/.test(e.line));
272
+ const iNarr2 = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'All read.');
273
+ assert.ok(iSum1 >= 0 && iNarr1 > iSum1, 'group #1 commits ABOVE the interim narration');
274
+ assert.ok(iSum2 > iNarr1 && iNarr2 > iSum2, 'group #2 commits below the interim narration and ABOVE the terminal answer');
275
+ });
276
+
277
+ // ───────────────────────────────────────────────────────────────────────────
278
+ // (g) read_file / list_dir have NULL permission descriptors → onPermissionAsk is
279
+ // never invoked for them, so the unconditional flush can never wrongly break
280
+ // an in-progress read/list group. (Groupable ⇒ null descriptor invariant.)
281
+ // ───────────────────────────────────────────────────────────────────────────
282
+ test('(g) read_file and list_dir have null permission descriptors (groupable ⇒ never reaches onPermissionAsk)', async () => {
283
+ const byTag = (t) => TOOL_REGISTRY.find((e) => Array.isArray(e.tags) && e.tags.includes(t));
284
+ const readEntry = byTag('read_file');
285
+ const listEntry = byTag('list_dir');
286
+ const writeEntry = byTag('write_file');
287
+
288
+ assert.ok(readEntry && typeof readEntry.permission === 'function', 'read_file entry present with a permission fn');
289
+ assert.ok(listEntry && typeof listEntry.permission === 'function', 'list_dir entry present with a permission fn');
290
+ assert.ok(writeEntry && typeof writeEntry.permission === 'function', 'write_file entry present with a permission fn');
291
+
292
+ // Groupable read-only tools: null descriptor → the loop's askGate is false →
293
+ // onPermissionAsk is NOT invoked for them.
294
+ assert.strictEqual(readEntry.permission({}, ['/a.js']), null, 'read_file descriptor is null');
295
+ assert.strictEqual(listEntry.permission({}, ['/d']), null, 'list_dir descriptor is null');
296
+
297
+ // Contrast: write_file (the prompting tool above) returns a NON-null descriptor.
298
+ // (_uiActive:true skips the headless diff branch, which would touch ctx.writer.)
299
+ const writeDesc = await writeEntry.permission({ _uiActive: true }, ['/out.js', 'x']);
300
+ assert.ok(writeDesc && typeof writeDesc === 'object' && writeDesc.tag === 'write_file',
301
+ 'write_file returns a non-null permission descriptor');
302
+ });
@@ -16,6 +16,8 @@ const {
16
16
  TIER_NET,
17
17
  TIER_SYS,
18
18
  } = require('../lib/permissions');
19
+ const dbg = require('../lib/debug');
20
+ const { loadRuleLayers } = require('../lib/permission-rules');
19
21
 
20
22
  // Minimal ui: interactiveSelect throws so any accidental fall-through to the
21
23
  // interactive path fails loudly instead of hanging on stdin.
@@ -155,6 +157,203 @@ test('clear() resets auto-approve-all back to the gated state', async () => {
155
157
  assert.strictEqual(pm.state.sessionApprovedTags.size, 0);
156
158
  });
157
159
 
160
+ // ── Per-command auto-approve line is gone by default; grant line stays once,
161
+ // debug breadcrumb preserved. (Drop the redundant "Auto-approved" line.) ──
162
+
163
+ // uiCallbacks that record every committed system message so we can assert the
164
+ // absence of a per-command "Auto-approved" line and the presence of the
165
+ // one-time grant line.
166
+ function recordingUICallbacks(actions = []) {
167
+ const messages = [];
168
+ return {
169
+ messages,
170
+ onShowModal: () => {},
171
+ onCloseModal: () => {},
172
+ onAddMessage: (m) => { messages.push(m); },
173
+ onCaptureNavigation: (handler) => {
174
+ setImmediate(() => { for (const a of actions) handler(a); });
175
+ return () => {};
176
+ },
177
+ };
178
+ }
179
+
180
+ function autoApprovedMessages(messages) {
181
+ return messages.filter((m) => typeof m.content === 'string' && m.content.includes('Auto-approved'));
182
+ }
183
+
184
+ test('default: an auto-approved command emits NO per-command "Auto-approved" line', async () => {
185
+ // exec tier pre-approves the `exec` tag → askPermission auto-approves without
186
+ // a prompt and would have called _emitAutoApproved.
187
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
188
+ const cb = recordingUICallbacks();
189
+ pm.setUICallbacks(cb);
190
+
191
+ assert.strictEqual(await pm.askPermission('exec', 'git status', 'exec'), true);
192
+ assert.strictEqual(await pm.askPermission('exec', 'ls -la', 'exec'), true);
193
+
194
+ assert.deepStrictEqual(
195
+ autoApprovedMessages(cb.messages),
196
+ [],
197
+ 'no per-command "Auto-approved: <cmd>" line should reach scrollback by default',
198
+ );
199
+ });
200
+
201
+ test('default: --dangerously-skip-permissions auto-approves with no per-command line', async () => {
202
+ const pm = createPermissionManager(uiStub, { skipPermissions: true });
203
+ const cb = recordingUICallbacks();
204
+ pm.setUICallbacks(cb);
205
+
206
+ assert.strictEqual(await pm.askPermission('exec', 'rm -rf build', 'exec'), true);
207
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
208
+ });
209
+
210
+ test('uniform across tools: a non-shell auto-approved tool emits no per-command line', async () => {
211
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['fs', 'net'] });
212
+ const cb = recordingUICallbacks();
213
+ pm.setUICallbacks(cb);
214
+
215
+ assert.strictEqual(await pm.askPermission('file', 'write src/a.js', 'write_file'), true);
216
+ assert.strictEqual(await pm.askPermission('net', 'fetch https://x', 'http_get'), true);
217
+
218
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
219
+ });
220
+
221
+ test('the one-time grant line still fires exactly once at "always", not per command', async () => {
222
+ await withTTY(async () => {
223
+ const pm = createPermissionManager(uiStub, {});
224
+ const cb = recordingUICallbacks(['next', 'select']); // Yes → Always
225
+ pm.setUICallbacks(cb);
226
+
227
+ // First call: interactive → "Always" grant.
228
+ assert.strictEqual(await pm.askPermission('exec', 'run once', 'exec'), true);
229
+ // Subsequent calls: auto-approved by the remembered tag (no modal).
230
+ assert.strictEqual(await pm.askPermission('exec', 'run twice', 'exec'), true);
231
+ assert.strictEqual(await pm.askPermission('exec', 'run thrice', 'exec'), true);
232
+
233
+ const grants = cb.messages.filter(
234
+ (m) => typeof m.content === 'string' && m.content.includes('Auto-approve enabled for'),
235
+ );
236
+ assert.strictEqual(grants.length, 1, 'grant line fires exactly once at grant time');
237
+ assert.deepStrictEqual(
238
+ autoApprovedMessages(cb.messages),
239
+ [],
240
+ 'no per-command "Auto-approved" line on the subsequent auto-approved commands',
241
+ );
242
+ });
243
+ });
244
+
245
+ test('--debug: the per-command auto-approve detail is preserved (incl. rule/skip context)', async () => {
246
+ // In simple (--debug) mode dbg.log routes synchronously to writer.scrollback;
247
+ // capture it to assert the breadcrumb (with rule context) is preserved.
248
+ const writer = require('../lib/ui/writer');
249
+ const origScrollback = writer.scrollback;
250
+ const captured = [];
251
+ writer.scrollback = (s) => { captured.push(String(s)); };
252
+ dbg.init({ debug: true });
253
+ try {
254
+ // A per-pattern `allow` rule auto-approves and embeds its `[rule: …]`
255
+ // context into the description handed to _emitAutoApproved.
256
+ const layers = loadRuleLayers(
257
+ { permissions: { rules: [{ tool: 'exec', match: '*', action: 'allow' }] } },
258
+ null,
259
+ null,
260
+ );
261
+ const pm = createPermissionManager(uiStub, { rules: layers, cwd: process.cwd() });
262
+ const cb = recordingUICallbacks();
263
+ pm.setUICallbacks(cb);
264
+
265
+ const verdict = pm.resolveRule(['exec', 'git status']);
266
+ assert.strictEqual(verdict.decision, 'allow', 'rule should resolve to allow');
267
+ assert.strictEqual(await pm.askPermission('exec', 'git status', 'exec', verdict), true);
268
+
269
+ // The per-command breadcrumb does NOT pollute the chat UI surface — it goes
270
+ // only to debug output.
271
+ assert.deepStrictEqual(autoApprovedMessages(cb.messages), []);
272
+
273
+ const breadcrumb = captured.find((s) => s.includes('auto-approved:'));
274
+ assert.ok(breadcrumb, 'debug output carries the per-command auto-approve breadcrumb');
275
+ assert.match(breadcrumb, /\[rule/, 'rule context preserved in debug detail');
276
+ assert.match(breadcrumb, /git status/, 'the command/description preserved in debug detail');
277
+ } finally {
278
+ dbg.close();
279
+ writer.scrollback = origScrollback;
280
+ }
281
+ });
282
+
283
+ // ── D1 (Output Refactor Phase 2): the permission close-summary is gone ──
284
+ //
285
+ // When a tool is manually approved, the modal close used to commit a redundant
286
+ // `✓ shell: ls` / `✓ file: Edit line N` summary line to scrollback — fully
287
+ // duplicating the execution result line that follows. That echo is removed:
288
+ // the result line (emitted by the agent loop, not the permission gate) is the
289
+ // SINGLE post-execution confirmation, so manual-approve now matches auto-approve.
290
+
291
+ // Callbacks that record both committed system messages AND any post-close
292
+ // summary string handed to onCloseModal, so we can assert the summary is absent.
293
+ function recordingModalCallbacks(actions = []) {
294
+ const messages = [];
295
+ const closeSummaries = [];
296
+ return {
297
+ messages,
298
+ closeSummaries,
299
+ onShowModal: () => {},
300
+ onCloseModal: (summary) => { if (summary !== undefined) closeSummaries.push(summary); },
301
+ onAddMessage: (m) => { messages.push(m); },
302
+ onCaptureNavigation: (handler) => {
303
+ setImmediate(() => { for (const a of actions) handler(a); });
304
+ return () => {};
305
+ },
306
+ };
307
+ }
308
+
309
+ test('D1: a manually-approved (Yes) call emits no close-summary line', async () => {
310
+ await withTTY(async () => {
311
+ const pm = createPermissionManager(uiStub, {});
312
+ const cb = recordingModalCallbacks(['select']); // Yes
313
+ pm.setUICallbacks(cb);
314
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
315
+ assert.deepStrictEqual(cb.closeSummaries, [], 'no close-summary committed to scrollback');
316
+ assert.deepStrictEqual(cb.messages, [], 'plain Yes commits nothing to scrollback');
317
+ });
318
+ });
319
+
320
+ test('D1: a denied (Esc → No) call also emits no close-summary line', async () => {
321
+ await withTTY(async () => {
322
+ const pm = createPermissionManager(uiStub, {});
323
+ const cb = recordingModalCallbacks(['cancel']); // Esc → No
324
+ pm.setUICallbacks(cb);
325
+ assert.strictEqual(await pm.askPermission('file', 'write src/a.js', 'write_file'), false);
326
+ assert.deepStrictEqual(cb.closeSummaries, [], 'no close-summary on denial either');
327
+ });
328
+ });
329
+
330
+ test('D1 parity: manual-approve (Yes) and auto-approve produce identical post-exec output', async () => {
331
+ // Manual approve: interactive Yes through the modal.
332
+ const manual = await withTTY(async () => {
333
+ const pm = createPermissionManager(uiStub, {});
334
+ const cb = recordingModalCallbacks(['select']); // Yes
335
+ pm.setUICallbacks(cb);
336
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
337
+ return { messages: cb.messages, closeSummaries: cb.closeSummaries };
338
+ });
339
+
340
+ // Auto-approve via the exec tier flag: no modal shown at all.
341
+ const auto = await (async () => {
342
+ const pm = createPermissionManager(uiStub, { allowedTiers: ['exec'] });
343
+ const cb = recordingModalCallbacks();
344
+ pm.setUICallbacks(cb);
345
+ assert.strictEqual(await pm.askPermission('shell', 'ls', 'exec'), true);
346
+ return { messages: cb.messages, closeSummaries: cb.closeSummaries };
347
+ })();
348
+
349
+ // Both commit NOTHING post-execution — the result line (emitted by the agent
350
+ // loop, not the permission gate) is the sole confirmation. This is the proof
351
+ // the close-summary was pure duplication.
352
+ assert.deepStrictEqual(manual, { messages: [], closeSummaries: [] });
353
+ assert.deepStrictEqual(auto, { messages: [], closeSummaries: [] });
354
+ assert.deepStrictEqual(manual, auto, 'manual-approve == auto-approve post-exec output');
355
+ });
356
+
158
357
  test('permission tiers map the expected tags', () => {
159
358
  assert.ok(TIER_EXEC.includes('exec'));
160
359
  assert.ok(TIER_FS.includes('write_file') && TIER_FS.includes('read_file'));
@@ -11,7 +11,7 @@
11
11
  // pathological long lines.
12
12
  //
13
13
  // Step 0 finding: edit_file is LINE-NUMBER-based (lines[lineNum-1] = content) and
14
- // replace_in_file is MATCH-based (regex on a search string). A MIX. Decision:
14
+ // replace_in_file is MATCH-based (literal-by-default search string). A MIX. Decision:
15
15
  // line numbers are OPTIONAL, default OFF (show_line_numbers). Rationale —
16
16
  // replace_in_file (the match-based path) needs raw, copyable line text, so
17
17
  // default-off keeps snippets verbatim AND avoids the ~1.7x token tax on every