@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.
- package/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +188 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +319 -52
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +229 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +542 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/narration-ordering.test.js +309 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permission-flush.test.js +302 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Output Refactor — Phase 1: ToolOperation descriptor + pure renderOperation.
|
|
4
|
+
//
|
|
5
|
+
// This phase introduces a single descriptor → one pure renderer as the spine
|
|
6
|
+
// for the interactive core tool line (shell/file/generic), replacing inline
|
|
7
|
+
// formatToolLine/buildExecutionDiff assembly at the chat-turn call sites. It
|
|
8
|
+
// must change NOTHING the user sees. These tests are the proof:
|
|
9
|
+
//
|
|
10
|
+
// 1. CHARACTERIZATION — pins the EXACT current bytes (glyphs, spacing, ANSI)
|
|
11
|
+
// for each representative path. Written against the captured output; they
|
|
12
|
+
// are the regression anchor.
|
|
13
|
+
// 2. EQUIVALENCE — renderOperation(buildToolOperation(x)) is byte-for-byte
|
|
14
|
+
// identical to the old formatToolLine(x) / buildExecutionDiff(x) for every
|
|
15
|
+
// path. This is the phase's pass/fail.
|
|
16
|
+
// 3. DESCRIPTOR DERIVATION — the descriptor is built correctly from the
|
|
17
|
+
// normalized [action, ...opts] tuple for each category, both rails.
|
|
18
|
+
//
|
|
19
|
+
// Phase 2.5 update: the PINNED bytes were re-pinned to the saturated palette.
|
|
20
|
+
// The line STRUCTURE is unchanged — only colour codes differ (see PINNED).
|
|
21
|
+
//
|
|
22
|
+
// Phase 3 update: the RUNNING (pending, non-blocking) glyph is now an animated
|
|
23
|
+
// spinner frame (the `tool` SPINNER_DEF) instead of the static dot, with the
|
|
24
|
+
// frame derived from durationMs (floor(ms/100) % frames.length). The blocking
|
|
25
|
+
// pending glyph (ask_user, noDuration) stays the static dot. Only those two
|
|
26
|
+
// running-phase glyphs changed; everything else is byte-identical (see PINNED).
|
|
27
|
+
|
|
28
|
+
const { test } = require('node:test');
|
|
29
|
+
const assert = require('node:assert');
|
|
30
|
+
|
|
31
|
+
// Phase 2.5: colour is gated on `isTTY && !NO_COLOR`. Force a colour-capable
|
|
32
|
+
// environment so the characterization bytes below are stable regardless of how
|
|
33
|
+
// the test runner wires stdout. (node:test runs each file in its own process,
|
|
34
|
+
// so this mutation does not leak to other suites.)
|
|
35
|
+
process.stdout.isTTY = true;
|
|
36
|
+
delete process.env.NO_COLOR;
|
|
37
|
+
|
|
38
|
+
const { formatToolLine } = require('../lib/ui/format');
|
|
39
|
+
const { buildExecutionDiff } = require('../lib/ui/diff');
|
|
40
|
+
const { buildToolOperation, serializeOperation } = require('../lib/ui/tool-operation');
|
|
41
|
+
const { renderOperation } = require('../lib/ui/render-operation');
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Representative tool-call lifecycle paths. `spec` is the data available at the
|
|
45
|
+
// onToolStart/onToolEnd callbacks; `phase` is which line we render.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const PATHS = [
|
|
48
|
+
{ name: 'shell pending', phase: 'pending', spec: { status: 'pending', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 1500 } },
|
|
49
|
+
{ name: 'shell result (ok)', phase: 'result', spec: { status: 'success', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 2300, meta: { exit_code: 0 } } },
|
|
50
|
+
{ name: 'shell result (error)', phase: 'result', spec: { status: 'failure', tag: 'shell', arg: 'npm test', attrs: { command: 'npm test' }, durationMs: 800, meta: { exit_code: 1 }, error: { message: 'exit 1', code: 1 } } },
|
|
51
|
+
{ name: 'file edit pending', phase: 'pending', spec: { status: 'pending', tag: 'write_file', arg: 'src/app.js', attrs: { path: 'src/app.js' }, durationMs: 100 } },
|
|
52
|
+
{ name: 'file edit result (ok)', phase: 'result', spec: { status: 'success', tag: 'write_file', arg: 'src/app.js', attrs: { path: 'src/app.js' }, durationMs: 50, meta: { bytes: 2048 } } },
|
|
53
|
+
{ name: 'http error result', phase: 'result', spec: { status: 'failure', tag: 'http_get', arg: 'https://x.example', attrs: { url: 'https://x.example' }, durationMs: 5000, error: { message: 'Request timeout', code: 'ETIMEDOUT' } } },
|
|
54
|
+
{ name: 'ask_user (blocking)', phase: 'pending', spec: { status: 'pending', tag: 'ask_user', arg: 'Proceed?', attrs: { question: 'Proceed?' }, noDuration: true } },
|
|
55
|
+
{ name: 'MCP tool result', phase: 'result', spec: { status: 'success', tag: 'mcp__server__lookup', arg: 'q', attrs: {}, durationMs: 120 } },
|
|
56
|
+
{ name: 'subagent result', phase: 'result', spec: { status: 'success', tag: 'spawn_agent', arg: 'task', attrs: {}, durationMs: 9000 } },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// The OLD call site: how chat-turn assembled formatToolLine args before Phase 1.
|
|
60
|
+
// pending lines carry no meta/error; result lines carry both.
|
|
61
|
+
function oldFormat(spec, phase) {
|
|
62
|
+
return formatToolLine({
|
|
63
|
+
status: spec.status,
|
|
64
|
+
tag: spec.tag,
|
|
65
|
+
arg: spec.arg,
|
|
66
|
+
attrs: spec.attrs,
|
|
67
|
+
durationMs: spec.durationMs,
|
|
68
|
+
meta: phase === 'pending' ? undefined : spec.meta,
|
|
69
|
+
error: phase === 'pending' ? undefined : spec.error,
|
|
70
|
+
noDuration: spec.noDuration,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 1. CHARACTERIZATION — the exact current bytes (the regression anchor).
|
|
76
|
+
//
|
|
77
|
+
// Re-pinned to the Phase 2.5 saturated palette (+ the Phase 3 animated running
|
|
78
|
+
// glyph). The line STRUCTURE is identical to Phase 1/2 (spacing, segments,
|
|
79
|
+
// separators); the result/blocking glyphs are unchanged, only the running glyph
|
|
80
|
+
// now animates. Phase 2.5 colour codes: categories saturated + differentiated
|
|
81
|
+
// (shell 214, file 77,
|
|
82
|
+
// net 39, mcp 141 with its own "mcp" label, tool fallback 245), statuses
|
|
83
|
+
// saturated (ok 40, error 203), the pending glyph category-tinted (never gray),
|
|
84
|
+
// the operation text now painted in the category colour, and durations lifted
|
|
85
|
+
// to subtle 244.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
const PINNED = {
|
|
88
|
+
'shell pending': ' \x1b[38;5;214m⣷\x1b[0m \x1b[38;5;214mshell\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;214mnpm install\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m1.5s…\x1b[0m',
|
|
89
|
+
'shell result (ok)': ' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;214mshell\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;214mnpm install\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m2.3s\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244mexit 0\x1b[0m',
|
|
90
|
+
'shell result (error)': ' \x1b[38;5;203m✗\x1b[0m \x1b[38;5;214mshell\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;214mnpm test\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;203m800ms\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;203mexit 1\x1b[0m',
|
|
91
|
+
'file edit pending': ' \x1b[38;5;77m⣽\x1b[0m \x1b[38;5;77mfile \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;77mwrite src/app.js\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m100ms…\x1b[0m',
|
|
92
|
+
'file edit result (ok)': ' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;77mfile \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;77mwrite src/app.js\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m50ms\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m2.0 KB\x1b[0m',
|
|
93
|
+
'http error result': ' \x1b[38;5;203m✗\x1b[0m \x1b[38;5;39mnet \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;39mGET https://x.example\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;203m5.0s\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;203mtimeout\x1b[0m',
|
|
94
|
+
'ask_user (blocking)': ' \x1b[38;5;211m●\x1b[0m \x1b[38;5;211muser \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;211mask Proceed?\x1b[0m',
|
|
95
|
+
'MCP tool result': ' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;141mmcp \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;141mmcp__server__lookup q\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m120ms\x1b[0m',
|
|
96
|
+
'subagent result': ' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;245mtool \x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;245mspawn_agent task\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m9.0s\x1b[0m',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
for (const { name, phase, spec } of PATHS) {
|
|
100
|
+
test(`characterization: '${name}' bytes are pinned`, () => {
|
|
101
|
+
assert.strictEqual(oldFormat(spec, phase), PINNED[name], 'current formatter output drifted from pinned bytes');
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// 2. EQUIVALENCE — descriptor→renderer == old formatter, byte-for-byte.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
for (const { name, phase, spec } of PATHS) {
|
|
109
|
+
test(`equivalence: '${name}' renderOperation == formatToolLine`, () => {
|
|
110
|
+
const op = buildToolOperation(spec);
|
|
111
|
+
const rendered = renderOperation(op, { mode: 'ansi', phase });
|
|
112
|
+
assert.strictEqual(rendered, oldFormat(spec, phase), 'renderer diverged from the old formatter');
|
|
113
|
+
// …and therefore equals the pinned bytes too.
|
|
114
|
+
assert.strictEqual(rendered, PINNED[name]);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// 2b. EQUIVALENCE — the diff detail body (file edit) renders identically.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
test('equivalence: diff detail == buildExecutionDiff', () => {
|
|
122
|
+
const diff = { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' };
|
|
123
|
+
const op = buildToolOperation({ tag: 'edit_file', arg: 'lib/x.js', attrs: { path: 'lib/x.js' }, status: 'ok', diff, durationMs: 7 });
|
|
124
|
+
const rendered = renderOperation(op, { mode: 'ansi', phase: 'detail', maxLines: 50 });
|
|
125
|
+
const old = buildExecutionDiff({ diff, maxLines: 50 });
|
|
126
|
+
assert.strictEqual(rendered, old);
|
|
127
|
+
assert.ok(rendered.length > 0, 'a real change renders a non-empty diff');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('equivalence: a no-op diff yields no detail (matches buildExecutionDiff null)', () => {
|
|
131
|
+
const diff = { before: 'same\n', after: 'same\n', path: 'x' };
|
|
132
|
+
const op = buildToolOperation({ tag: 'edit_file', arg: 'x', attrs: { path: 'x' }, status: 'ok', diff });
|
|
133
|
+
// buildExecutionDiff returns null for an unchanged edit; the renderer returns ''.
|
|
134
|
+
assert.strictEqual(buildExecutionDiff({ diff, maxLines: 50 }), null);
|
|
135
|
+
assert.strictEqual(renderOperation(op, { mode: 'ansi', phase: 'detail', maxLines: 50 }), '');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// 3. DESCRIPTOR DERIVATION — built correctly from the normalized tuple. Both
|
|
140
|
+
// the XML rail and the native rail converge on the SAME [action, ...opts]
|
|
141
|
+
// tuple, so a descriptor built from that tuple is rail-agnostic by construction.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
test('descriptor: shell category/target/status/glyph from the tuple', () => {
|
|
145
|
+
const op = buildToolOperation({ tag: 'shell', arg: 'ls -la', attrs: { command: 'ls -la' }, status: 'ok', durationMs: 30, meta: { exit_code: 0 } });
|
|
146
|
+
assert.strictEqual(op.category, 'shell');
|
|
147
|
+
assert.strictEqual(op.target, 'ls -la');
|
|
148
|
+
assert.strictEqual(op.status, 'ok');
|
|
149
|
+
assert.strictEqual(op.glyph, '✓');
|
|
150
|
+
assert.deepStrictEqual(op.meta, { exit_code: 0 });
|
|
151
|
+
assert.strictEqual(op.detail, null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('descriptor: file edit carries a diff detail when present', () => {
|
|
155
|
+
const diff = { before: '1\n', after: '2\n', path: 'f' };
|
|
156
|
+
const op = buildToolOperation({ tag: 'write_file', arg: 'f', attrs: { path: 'f' }, status: 'ok', diff });
|
|
157
|
+
assert.strictEqual(op.category, 'file');
|
|
158
|
+
assert.strictEqual(op.detail.kind, 'diff');
|
|
159
|
+
assert.deepStrictEqual(op.detail.payload, { before: '1\n', after: '2\n', path: 'f' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('descriptor: status normalization — success/failure → ok/error, error forces error', () => {
|
|
163
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'success' }).status, 'ok');
|
|
164
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'failure' }).status, 'error');
|
|
165
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'ok', error: { message: 'x' } }).status, 'error');
|
|
166
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'pending' }).status, 'pending');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('descriptor: glyph tracks status', () => {
|
|
170
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'pending' }).glyph, '●');
|
|
171
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'ok' }).glyph, '✓');
|
|
172
|
+
assert.strictEqual(buildToolOperation({ tag: 'shell', status: 'failure' }).glyph, '✗');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('descriptor: net category for http_get; error status suppresses diff detail', () => {
|
|
176
|
+
const op = buildToolOperation({ tag: 'http_get', arg: 'https://x', attrs: { url: 'https://x' }, status: 'error', error: { message: 'boom' }, diff: { before: 'a', after: 'b', path: 'p' } });
|
|
177
|
+
assert.strictEqual(op.category, 'net');
|
|
178
|
+
assert.strictEqual(op.detail, null, 'a failed op carries no diff detail');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('descriptor: git_* and mcp tags resolve to their own categories (no longer the tool fallback)', () => {
|
|
182
|
+
assert.strictEqual(buildToolOperation({ tag: 'git_commit', status: 'ok' }).category, 'git');
|
|
183
|
+
assert.strictEqual(buildToolOperation({ tag: 'mcp__srv__do', arg: 'x', attrs: {}, status: 'ok' }).category, 'mcp');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('descriptor: a truly unknown tag falls back to the tool category; is frozen', () => {
|
|
187
|
+
const op = buildToolOperation({ tag: 'spawn_agent', arg: 'x', attrs: {}, status: 'ok' });
|
|
188
|
+
assert.strictEqual(op.category, 'tool');
|
|
189
|
+
assert.ok(Object.isFrozen(op), 'descriptor is immutable');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('descriptor: action-name tuple (read/write/append/exec) normalizes for category', () => {
|
|
193
|
+
// The native rail may hand action names that differ from tag names; the
|
|
194
|
+
// descriptor normalizes them the same way the renderer does.
|
|
195
|
+
assert.strictEqual(buildToolOperation({ tag: 'exec', status: 'ok' }).category, 'shell');
|
|
196
|
+
assert.strictEqual(buildToolOperation({ tag: 'read', status: 'ok' }).category, 'file');
|
|
197
|
+
assert.strictEqual(buildToolOperation({ tag: 'write', status: 'ok' }).category, 'file');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Renderer guards: unknown mode/empty descriptor degrade safely (no throw).
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
test('renderOperation: unknown mode and null descriptor degrade safely', () => {
|
|
204
|
+
// null descriptor → '' in every mode (guard: no throw on absent op).
|
|
205
|
+
assert.strictEqual(renderOperation(null, { mode: 'ansi', phase: 'result' }), '');
|
|
206
|
+
assert.strictEqual(renderOperation(null, { mode: 'json' }), '');
|
|
207
|
+
// an unknown mode falls through to '' (guard: forward-compat, never throw).
|
|
208
|
+
const op = buildToolOperation({ tag: 'shell', status: 'ok', durationMs: 1 });
|
|
209
|
+
assert.strictEqual(renderOperation(op, { mode: 'totally-unknown' }), '');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Phase 6d-i: json/event are PURE structured-data modes over the descriptor,
|
|
214
|
+
// built from the SAME operationCore as serializeOperation (one serializer). They
|
|
215
|
+
// emit ZERO ANSI, do no IO, and add no framing — that is the headless sink's job
|
|
216
|
+
// (6d-ii). The descriptors below cover a normal op, an error op, and a diff op.
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
const JSON_OPS = {
|
|
219
|
+
'normal tool': buildToolOperation({ status: 'success', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 2300, meta: { exit_code: 0 } }),
|
|
220
|
+
'error op': buildToolOperation({ status: 'failure', tag: 'http_get', arg: 'https://x.example', attrs: { url: 'https://x.example' }, durationMs: 5000, error: { message: 'Request timeout', code: 'ETIMEDOUT' } }),
|
|
221
|
+
'diff op': buildToolOperation({ tag: 'edit_file', arg: 'lib/x.js', attrs: { path: 'lib/x.js' }, status: 'ok', diff: { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' }, durationMs: 7 }),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// 1. json structure — descriptor-native fields with the RIGHT VALUES (not just
|
|
225
|
+
// presence). The whole object is asserted by deep-equality against the
|
|
226
|
+
// descriptor's own core (no `v`, no `id`/`glyph` live fields, no ANSI).
|
|
227
|
+
test('json: structured descriptor object with correct field VALUES', () => {
|
|
228
|
+
assert.deepStrictEqual(renderOperation(JSON_OPS['normal tool'], { mode: 'json' }), {
|
|
229
|
+
tag: 'shell', target: 'npm install', attrs: { command: 'npm install' }, category: 'shell',
|
|
230
|
+
status: 'ok', durationMs: 2300, meta: { exit_code: 0 }, error: null, noDuration: false, detail: null,
|
|
231
|
+
});
|
|
232
|
+
assert.deepStrictEqual(renderOperation(JSON_OPS['error op'], { mode: 'json' }), {
|
|
233
|
+
tag: 'http_get', target: 'https://x.example', attrs: { url: 'https://x.example' }, category: 'net',
|
|
234
|
+
status: 'error', durationMs: 5000, meta: null, error: { message: 'Request timeout', code: 'ETIMEDOUT' },
|
|
235
|
+
noDuration: false, detail: null,
|
|
236
|
+
});
|
|
237
|
+
assert.deepStrictEqual(renderOperation(JSON_OPS['diff op'], { mode: 'json' }), {
|
|
238
|
+
tag: 'edit_file', target: 'lib/x.js', attrs: { path: 'lib/x.js' }, category: 'file', status: 'ok',
|
|
239
|
+
durationMs: 7, meta: null, error: null, noDuration: false,
|
|
240
|
+
detail: { kind: 'diff', payload: { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' } },
|
|
241
|
+
});
|
|
242
|
+
// No live-only / persistence-only fields leak into the embeddable form.
|
|
243
|
+
for (const op of Object.values(JSON_OPS)) {
|
|
244
|
+
const j = renderOperation(op, { mode: 'json' });
|
|
245
|
+
assert.ok(!('id' in j) && !('glyph' in j) && !('v' in j), 'json drops id/glyph/v');
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// 2. event structure — the json object PLUS the lifecycle `phase` it rendered
|
|
250
|
+
// for, and NOTHING ELSE. (Defined relationship: event === { ...json, phase }.)
|
|
251
|
+
test('event: json object plus the lifecycle phase, and nothing else', () => {
|
|
252
|
+
for (const [name, op] of Object.entries(JSON_OPS)) {
|
|
253
|
+
const json = renderOperation(op, { mode: 'json' });
|
|
254
|
+
const event = renderOperation(op, { mode: 'event', phase: 'result' });
|
|
255
|
+
assert.deepStrictEqual(event, { ...json, phase: 'result' }, `event == {...json, phase} for '${name}'`);
|
|
256
|
+
}
|
|
257
|
+
// phase is carried through verbatim; default is 'result' when omitted.
|
|
258
|
+
assert.strictEqual(renderOperation(JSON_OPS['normal tool'], { mode: 'event', phase: 'pending' }).phase, 'pending');
|
|
259
|
+
assert.strictEqual(renderOperation(JSON_OPS['normal tool'], { mode: 'event' }).phase, 'result');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// 3. single source of truth — json's persistable overlap with serializeOperation
|
|
263
|
+
// is IDENTICAL (they share one operationCore; serialize only adds `v`). Proven
|
|
264
|
+
// on the overlapping keys so the one-mapping invariant can't silently diverge.
|
|
265
|
+
test('json: persistable overlap is identical to serializeOperation (one mapping)', () => {
|
|
266
|
+
for (const op of Object.values(JSON_OPS)) {
|
|
267
|
+
const json = renderOperation(op, { mode: 'json' });
|
|
268
|
+
const ser = serializeOperation(op);
|
|
269
|
+
const overlap = {};
|
|
270
|
+
for (const k of Object.keys(json)) overlap[k] = ser[k];
|
|
271
|
+
assert.deepStrictEqual(json, overlap, 'json == serializeOperation on shared keys');
|
|
272
|
+
// serialize is exactly json + the `v` persistence tag — no other difference.
|
|
273
|
+
assert.deepStrictEqual(ser, { v: 1, ...json });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// 4a. ANTI-PING-PONG — serializeOperation output is byte-identical to before this
|
|
278
|
+
// commit (snapshots captured pre-6d-i). Guards replay/persistence (6a–6c).
|
|
279
|
+
test('anti-ping-pong: serializeOperation bytes are pinned (replay/persist guard)', () => {
|
|
280
|
+
const SER_PINNED = {
|
|
281
|
+
'normal tool': '{"v":1,"tag":"shell","target":"npm install","attrs":{"command":"npm install"},"category":"shell","status":"ok","durationMs":2300,"meta":{"exit_code":0},"error":null,"noDuration":false,"detail":null}',
|
|
282
|
+
'error op': '{"v":1,"tag":"http_get","target":"https://x.example","attrs":{"url":"https://x.example"},"category":"net","status":"error","durationMs":5000,"meta":null,"error":{"message":"Request timeout","code":"ETIMEDOUT"},"noDuration":false,"detail":null}',
|
|
283
|
+
'diff op': '{"v":1,"tag":"edit_file","target":"lib/x.js","attrs":{"path":"lib/x.js"},"category":"file","status":"ok","durationMs":7,"meta":null,"error":null,"noDuration":false,"detail":{"kind":"diff","payload":{"before":"a\\nb\\nc\\n","after":"a\\nB\\nc\\n","path":"lib/x.js"}}}',
|
|
284
|
+
};
|
|
285
|
+
for (const [name, op] of Object.entries(JSON_OPS)) {
|
|
286
|
+
assert.strictEqual(JSON.stringify(serializeOperation(op)), SER_PINNED[name], `serialize bytes drifted for '${name}'`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// 4b. ANTI-PING-PONG — the ansi render is byte-identical to before. Guards the
|
|
291
|
+
// interactive path (Phase 1). Re-uses the PINNED table above as the anchor.
|
|
292
|
+
test('anti-ping-pong: ansi render bytes are pinned (interactive path guard)', () => {
|
|
293
|
+
for (const { name, phase, spec } of PATHS) {
|
|
294
|
+
const op = buildToolOperation(spec);
|
|
295
|
+
assert.strictEqual(renderOperation(op, { mode: 'ansi', phase }), PINNED[name], `ansi bytes drifted for '${name}'`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// 5. PURITY — json/event carry no ANSI escape sequences and the calls perform no
|
|
300
|
+
// stdout IO (the renderer must not write; framing/IO is the consumer's job).
|
|
301
|
+
test('purity: json/event contain no ANSI and write nothing to stdout', () => {
|
|
302
|
+
const ESC = /\x1b\[/;
|
|
303
|
+
const origWrite = process.stdout.write;
|
|
304
|
+
let wrote = 0;
|
|
305
|
+
process.stdout.write = function () { wrote++; return true; };
|
|
306
|
+
try {
|
|
307
|
+
for (const op of Object.values(JSON_OPS)) {
|
|
308
|
+
const j = renderOperation(op, { mode: 'json' });
|
|
309
|
+
const e = renderOperation(op, { mode: 'event', phase: 'result' });
|
|
310
|
+
assert.ok(!ESC.test(JSON.stringify(j)), 'json has no ANSI');
|
|
311
|
+
assert.ok(!ESC.test(JSON.stringify(e)), 'event has no ANSI');
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
process.stdout.write = origWrite;
|
|
315
|
+
}
|
|
316
|
+
assert.strictEqual(wrote, 0, 'json/event modes performed no stdout IO');
|
|
317
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Output Refactor — Phase 6b: XML-rail replay parity via a persisted descriptor array.
|
|
4
|
+
//
|
|
5
|
+
// The XML rail folds ALL tool results of a turn into ONE {role:'user'} feedback
|
|
6
|
+
// blob (agent.js): `Tool execution results:\n\n${results.join('\n\n')}\n\n…`. On
|
|
7
|
+
// replay that blob was rendered as a single lossy summarizeToolResult line — no
|
|
8
|
+
// per-call diff/duration/status. It cannot be split back by parsing (the only
|
|
9
|
+
// separator, \n\n, appears freely inside result bodies). Phase 6b persists the
|
|
10
|
+
// per-call display descriptors as a sibling `_display[]` aligned 1:1 with the
|
|
11
|
+
// results and, ON A GATE, replays each through the SAME native-rail render. The
|
|
12
|
+
// gate is the crux: per-call rendering happens ONLY when every slot is a
|
|
13
|
+
// non-null known-version core; any `null` (a web op — out of scope until 6c) or
|
|
14
|
+
// unknown version drops the whole blob to the unchanged legacy summary.
|
|
15
|
+
//
|
|
16
|
+
// These tests pin:
|
|
17
|
+
// 1. POSITIVE — an all-non-null `_display[]` replays as N per-call lines,
|
|
18
|
+
// byte-identical to the native rail render of each descriptor, in order.
|
|
19
|
+
// 2. NEGATIVE / no-regression gate — no `_display`, a `null` slot, and an
|
|
20
|
+
// unknown-version slot ALL fall back to the unchanged whole-blob summary.
|
|
21
|
+
// 3. INV.1 — attaching `_display[]` leaves the feedback `content` byte-identical.
|
|
22
|
+
// 4. ANTI-PING-PONG — native (6a) and Phase 1 fresh-render bytes are unchanged.
|
|
23
|
+
|
|
24
|
+
const { test } = require('node:test');
|
|
25
|
+
const assert = require('node:assert');
|
|
26
|
+
|
|
27
|
+
// Force a colour-capable env so byte comparisons are stable (node:test runs each
|
|
28
|
+
// file in its own process — no leak to other suites). Mirrors the 6a suite.
|
|
29
|
+
process.stdout.isTTY = true;
|
|
30
|
+
delete process.env.NO_COLOR;
|
|
31
|
+
|
|
32
|
+
const { buildToolOperation, serializeOperation, descriptorFromStored } = require('../lib/ui/tool-operation');
|
|
33
|
+
const { renderOperation } = require('../lib/ui/render-operation');
|
|
34
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
35
|
+
const { summarizeToolResult } = require('../lib/ui/format');
|
|
36
|
+
const { createChatSession } = require('../lib/commands/chat-session');
|
|
37
|
+
|
|
38
|
+
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
39
|
+
|
|
40
|
+
// ── Fixtures: completed ops spanning the fidelity dimensions the summary drops —
|
|
41
|
+
// a file-edit DIFF, a SHELL ERROR, and a plain SHELL OK, with distinct durations.
|
|
42
|
+
const EDIT_DIFF = buildToolOperation({
|
|
43
|
+
id: 'tool-1', tag: 'edit_file', arg: 'lib/x.js', attrs: { path: 'lib/x.js' },
|
|
44
|
+
status: 'ok', durationMs: 12, diff: { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' },
|
|
45
|
+
});
|
|
46
|
+
const SHELL_ERR = buildToolOperation({
|
|
47
|
+
id: 'tool-2', tag: 'shell', arg: 'npm test', attrs: { command: 'npm test' },
|
|
48
|
+
status: 'error', durationMs: 800, meta: { exit_code: 1 }, error: { message: 'exit 1', code: 1 },
|
|
49
|
+
});
|
|
50
|
+
const SHELL_OK = buildToolOperation({
|
|
51
|
+
id: 'tool-3', tag: 'shell', arg: 'npm run lint', attrs: { command: 'npm run lint' },
|
|
52
|
+
status: 'ok', durationMs: 2300, meta: { exit_code: 0 },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const CFG = { diff_max_lines: 50, shell_preview_lines: 5 };
|
|
56
|
+
|
|
57
|
+
// Build the XML feedback blob EXACTLY as agent.js does, with an optional aligned
|
|
58
|
+
// `_display[]`. `results` are arbitrary per-call body strings (their bytes are
|
|
59
|
+
// irrelevant to display when a descriptor restores — only `content` identity is).
|
|
60
|
+
function xmlBlob(results, display) {
|
|
61
|
+
const m = {
|
|
62
|
+
role: 'user',
|
|
63
|
+
content: `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
|
|
64
|
+
};
|
|
65
|
+
if (display !== undefined) m._display = display;
|
|
66
|
+
return m;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Drive the REAL replay function (chat-session.displayLoadedMessages) over one
|
|
70
|
+
// loaded message and capture exactly what gets committed to scrollback. Only
|
|
71
|
+
// `chatHistory` + `getConfig` are exercised by the blob branch under test.
|
|
72
|
+
function replay(loadedMessage, cfg) {
|
|
73
|
+
const ch = new ChatHistory();
|
|
74
|
+
const out = [];
|
|
75
|
+
ch._commit = (t) => out.push(t);
|
|
76
|
+
const session = createChatSession({ chatHistory: ch, getConfig: () => cfg || CFG });
|
|
77
|
+
session.displayLoadedMessages([loadedMessage]);
|
|
78
|
+
return out.join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Oracle: what the NATIVE rail commits for one descriptor core (the exact path a
|
|
82
|
+
// per-call XML line must reproduce). Renders through addMessage so it includes
|
|
83
|
+
// the result line + any diff/output detail chrome, byte-for-byte.
|
|
84
|
+
function nativeReplay(core, cfg) {
|
|
85
|
+
const c = cfg || CFG;
|
|
86
|
+
const ch = new ChatHistory();
|
|
87
|
+
const out = [];
|
|
88
|
+
ch._commit = (t) => out.push(t);
|
|
89
|
+
ch.addMessage({
|
|
90
|
+
role: 'tool', tag: 'tool', content: '', _display: core,
|
|
91
|
+
diffMaxLines: c.diff_max_lines, previewLines: c.shell_preview_lines || 5,
|
|
92
|
+
});
|
|
93
|
+
return out.join('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
97
|
+
// 1. POSITIVE — all-non-null `_display[]` → N per-call lines, byte-identical to
|
|
98
|
+
// the native render of each descriptor, in call order (guards XML parity).
|
|
99
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
100
|
+
test('replay: XML blob with all-non-null _display[] renders N per-call lines == native render, in order (guards XML per-call parity)', () => {
|
|
101
|
+
const cores = [serializeOperation(EDIT_DIFF), serializeOperation(SHELL_ERR), serializeOperation(SHELL_OK)];
|
|
102
|
+
const committed = replay(xmlBlob(['edited lib/x.js', 'FAIL', 'lint clean'], cores), CFG);
|
|
103
|
+
|
|
104
|
+
// Byte-identical to concatenating the native rail's render of each descriptor.
|
|
105
|
+
const expected = cores.map((c) => nativeReplay(c, CFG)).join('');
|
|
106
|
+
assert.strictEqual(committed, expected, 'per-call lines are byte-identical to the native rail, in call order');
|
|
107
|
+
|
|
108
|
+
// And it is not vacuous: the fidelity the summary drops is actually present.
|
|
109
|
+
const text = stripAnsi(committed);
|
|
110
|
+
assert.match(text, /2\.3s/, 'shell-ok real duration shown');
|
|
111
|
+
assert.match(text, /exit 0/, 'shell-ok real exit meta shown');
|
|
112
|
+
assert.match(text, /exit 1/, 'shell-error real exit meta shown');
|
|
113
|
+
// The edit diff body renders (a line removed / a line added).
|
|
114
|
+
const diffBody = renderOperation(descriptorFromStored(cores[0]), { mode: 'ansi', phase: 'detail', maxLines: 50 });
|
|
115
|
+
assert.ok(diffBody.length > 0 && committed.includes(diffBody), 'the edit diff body is replayed in full');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// 2. NEGATIVE / no-regression gate — no `_display`, a `null` slot, and an
|
|
120
|
+
// unknown-version slot all fall back to the SAME unchanged whole-blob summary.
|
|
121
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
// Snapshot oracle for today's whole-blob behaviour (header/footer stripped,
|
|
124
|
+
// collapsed via summarizeToolResult — exactly the legacy fall-through).
|
|
125
|
+
function legacyWholeBlob(blobMsg) {
|
|
126
|
+
const raw = blobMsg.content;
|
|
127
|
+
const body = raw
|
|
128
|
+
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
129
|
+
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
130
|
+
.trim();
|
|
131
|
+
const ch = new ChatHistory();
|
|
132
|
+
const out = [];
|
|
133
|
+
ch._commit = (t) => out.push(t);
|
|
134
|
+
ch.addMessage({ role: 'tool', tag: 'tool', content: body || raw });
|
|
135
|
+
return out.join('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test('replay: XML blob with NO _display falls back to the whole-blob summary, byte-identical to today (guards legacy fallback)', () => {
|
|
139
|
+
const blob = xmlBlob(['Command `npm test`:\nExit code: 0\nall good']); // no _display
|
|
140
|
+
const committed = replay(blob, CFG);
|
|
141
|
+
assert.strictEqual(committed, legacyWholeBlob(blob), 'no-_display replay is byte-identical to the legacy whole-blob summary');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('replay: XML blob whose _display[] has ONE null slot falls back to the whole-blob summary (guards web no-regression)', () => {
|
|
145
|
+
// A null slot stands in for a web op (intercepted away from buildToolOperation,
|
|
146
|
+
// out of scope until 6c). Rendering only the non-null slots would make the web
|
|
147
|
+
// op vanish — so the WHOLE blob must stay on the legacy summary.
|
|
148
|
+
const blob = xmlBlob(
|
|
149
|
+
['edited lib/x.js', 'fetched https://example.com'],
|
|
150
|
+
[serializeOperation(EDIT_DIFF), null],
|
|
151
|
+
);
|
|
152
|
+
const committed = replay(blob, CFG);
|
|
153
|
+
assert.strictEqual(committed, legacyWholeBlob(blob), 'one null slot → unchanged whole-blob summary');
|
|
154
|
+
// It must NOT render the non-null EDIT_DIFF slot individually.
|
|
155
|
+
const diffBody = renderOperation(descriptorFromStored(serializeOperation(EDIT_DIFF)), { mode: 'ansi', phase: 'detail', maxLines: 50 });
|
|
156
|
+
assert.ok(diffBody.length > 0, 'precondition: the edit slot has a diff body that would have shown');
|
|
157
|
+
assert.ok(!committed.includes(diffBody), 'no partial render — the non-null slot is NOT rendered individually');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('replay: XML blob whose _display[] has an unknown-version (v:999) slot falls back to the whole-blob summary (guards version gate)', () => {
|
|
161
|
+
const blob = xmlBlob(
|
|
162
|
+
['edited lib/x.js', 'something'],
|
|
163
|
+
[serializeOperation(EDIT_DIFF), { v: 999, tag: 'shell', status: 'ok' }],
|
|
164
|
+
);
|
|
165
|
+
const committed = replay(blob, CFG);
|
|
166
|
+
assert.strictEqual(committed, legacyWholeBlob(blob), 'unknown-version slot → unchanged whole-blob summary');
|
|
167
|
+
// Sanity: the summary path was taken (the collapsed one-liner is present).
|
|
168
|
+
const body = blob.content
|
|
169
|
+
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
170
|
+
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
171
|
+
.trim();
|
|
172
|
+
assert.match(stripAnsi(committed), new RegExp(summarizeToolResult(body).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// 3. INV.1 — attaching `_display[]` is additive: the feedback `content` is
|
|
177
|
+
// byte-identical to the original results.join('\n\n')-wrapped string, and
|
|
178
|
+
// `_display` carries the aligned array (never folded into content).
|
|
179
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
180
|
+
test('inv.1: attaching _display[] leaves the feedback content byte-identical (guards the model-facing chokepoint)', () => {
|
|
181
|
+
const results = ['Command `npm test`:\nExit code: 0\nall good', 'Updated lib/x.js'];
|
|
182
|
+
const cores = [serializeOperation(SHELL_OK), serializeOperation(EDIT_DIFF)];
|
|
183
|
+
// Model the agent.js push: content from results.join, _display attached aside.
|
|
184
|
+
const expectedContent = `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`;
|
|
185
|
+
const msg = xmlBlob(results, cores);
|
|
186
|
+
assert.strictEqual(msg.content, expectedContent, 'content equals the results.join-wrapped string, byte-for-byte');
|
|
187
|
+
assert.ok(!msg.content.includes('_display'), 'no descriptor framing in content');
|
|
188
|
+
assert.ok(!msg.content.includes('2.3s'), 'no descriptor duration leaked into content');
|
|
189
|
+
// _display is the aligned array, preserved with its slots.
|
|
190
|
+
assert.ok(Array.isArray(msg._display) && msg._display.length === 2);
|
|
191
|
+
assert.strictEqual(msg._display[0].durationMs, 2300);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// 4. ANTI-PING-PONG — 6b did not regress the native (6a) replay parity nor the
|
|
196
|
+
// Phase 1 fresh-render bytes (threading the array changed neither path).
|
|
197
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
198
|
+
test('anti-ping-pong: native (6a) round-trip still byte-identical (guards no 6a regression)', () => {
|
|
199
|
+
const restored = descriptorFromStored(serializeOperation(EDIT_DIFF));
|
|
200
|
+
assert.strictEqual(
|
|
201
|
+
renderOperation(restored, { mode: 'ansi' }),
|
|
202
|
+
renderOperation(EDIT_DIFF, { mode: 'ansi' }),
|
|
203
|
+
'native rail result line round-trips byte-identical',
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('anti-ping-pong: Phase 1 fresh-render bytes unchanged (guards no live-path regression)', () => {
|
|
208
|
+
const shellOk = renderOperation(
|
|
209
|
+
buildToolOperation({ status: 'success', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 2300, meta: { exit_code: 0 } }),
|
|
210
|
+
{ mode: 'ansi', phase: 'result' },
|
|
211
|
+
);
|
|
212
|
+
assert.strictEqual(
|
|
213
|
+
shellOk,
|
|
214
|
+
' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;214mshell\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;214mnpm install\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m2.3s\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244mexit 0\x1b[0m',
|
|
215
|
+
);
|
|
216
|
+
});
|