@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,309 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Activity-group ordering vs assistant narration (Option b — "fix: flush activity
|
|
4
|
+
// groups before content-bearing narration for correct ordering").
|
|
5
|
+
//
|
|
6
|
+
// THE BUG: assistant narration commits to immutable scrollback immediately
|
|
7
|
+
// (streamed token-by-token via streamToken, finalized via finalizeLastMessage),
|
|
8
|
+
// while an open file/web activity group lives in the redrawable activity region
|
|
9
|
+
// BELOW scrollback and only commits at a later boundary. When narration was
|
|
10
|
+
// INTERMEDIATE (a tool follows), the old terminal-only flush gate was skipped, so
|
|
11
|
+
// the narration committed ABOVE the still-open group and the group flushed later,
|
|
12
|
+
// landing BELOW the conclusion it was based on (the "list ×3 below 'directory
|
|
13
|
+
// almost empty'" screenshot).
|
|
14
|
+
//
|
|
15
|
+
// THE FIX (Option b): flush the open group BEFORE any content-bearing narration.
|
|
16
|
+
// • Streamed path — flush at streaming-START (onToken, before the first
|
|
17
|
+
// content-bearing token commits the "▸ AI-agent" header + line to scrollback).
|
|
18
|
+
// • Finalize path — flush at onAssistantMessage on terminal OR content-bearing
|
|
19
|
+
// narration (backstop for the non-streamed path).
|
|
20
|
+
// • Silent runs (empty/whitespace-only interim narration) still fully collapse.
|
|
21
|
+
//
|
|
22
|
+
// Tests drive the REAL createTurnHandler callbacks (same harness shape as
|
|
23
|
+
// permission-flush.test.js / file-activity.test.js), recording every committed
|
|
24
|
+
// scrollback line and every narration line in ONE ordered log so ordering can be
|
|
25
|
+
// asserted directly.
|
|
26
|
+
|
|
27
|
+
const { test } = require('node:test');
|
|
28
|
+
const assert = require('node:assert');
|
|
29
|
+
|
|
30
|
+
process.stdout.isTTY = true;
|
|
31
|
+
delete process.env.NO_COLOR;
|
|
32
|
+
|
|
33
|
+
const { stripAnsi } = require('../lib/ui/utils');
|
|
34
|
+
const { createTurnHandler } = require('../lib/commands/chat-turn');
|
|
35
|
+
|
|
36
|
+
// ── Live harness ──────────────────────────────────────────────────────────────
|
|
37
|
+
// `turnOpts` becomes ctx.opts. Pass { showThink: true } to disable the
|
|
38
|
+
// implicit-think buffering gate so onToken streams tokens straight to
|
|
39
|
+
// chatHistory.streamToken (exercising the streamed-narration path).
|
|
40
|
+
function harness(turnOpts) {
|
|
41
|
+
const events = [];
|
|
42
|
+
// streaming flag mirrors chat-history's _streamActive: set on the first
|
|
43
|
+
// streamToken, cleared on clear/finalize. isStreaming() drives the
|
|
44
|
+
// streaming-start flush gate in onToken.
|
|
45
|
+
let streaming = false;
|
|
46
|
+
|
|
47
|
+
const writerModule = {
|
|
48
|
+
startActivity() {}, updateActivity() {},
|
|
49
|
+
endActivity(id, line) {
|
|
50
|
+
for (const raw of String(line == null ? '' : line).split('\n')) {
|
|
51
|
+
if (raw === '') continue;
|
|
52
|
+
events.push({ kind: 'commit', line: stripAnsi(raw) });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
scrollback(line) { events.push({ kind: 'scrollback', line: stripAnsi(String(line)) }); },
|
|
56
|
+
};
|
|
57
|
+
const chatHistory = {
|
|
58
|
+
addMessage(m) { if (m && m.isError) events.push({ kind: 'error', output: m.output }); },
|
|
59
|
+
streamToken(t) {
|
|
60
|
+
if (!t) return;
|
|
61
|
+
// Mirror the real per-line commit: the header on the first token, then the
|
|
62
|
+
// token text as a committed narration line. (We don't need byte-fidelity —
|
|
63
|
+
// only that a narration line lands in the ordered log when the model speaks.)
|
|
64
|
+
if (!streaming) { streaming = true; events.push({ kind: 'narration', line: '▸ AI-agent' }); }
|
|
65
|
+
events.push({ kind: 'narration', line: t });
|
|
66
|
+
},
|
|
67
|
+
isStreaming() { return streaming; },
|
|
68
|
+
clearStreamingContent() { streaming = false; },
|
|
69
|
+
deferToolOutput() {}, commitDeferredDetail() {},
|
|
70
|
+
finalizeLastMessage(content) {
|
|
71
|
+
streaming = false;
|
|
72
|
+
if (content && content.trim()) events.push({ kind: 'answer', content });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const statusBar = { update() {}, onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {} };
|
|
76
|
+
const inputField = { on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {} };
|
|
77
|
+
|
|
78
|
+
let scenario = async () => {};
|
|
79
|
+
const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
|
|
80
|
+
await scenario(loopOpts.callbacks);
|
|
81
|
+
return { messages, metrics: { turns: [] }, withheldActions: [] };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ctx = {
|
|
85
|
+
inputField, statusBar, chatHistory, writerModule, runAgentLoop,
|
|
86
|
+
getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
|
|
87
|
+
approxTokens: () => 0,
|
|
88
|
+
resolveCommand: () => null,
|
|
89
|
+
opts: turnOpts || {},
|
|
90
|
+
TAG_REGISTRY: {},
|
|
91
|
+
collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
|
|
92
|
+
activateNavCapture() {}, finalizeListMsg() {},
|
|
93
|
+
createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
|
|
94
|
+
messages: [], currentModel: 'm', debugMode: false, pendingImages: [],
|
|
95
|
+
chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handler = createTurnHandler(ctx, {});
|
|
99
|
+
return { events, handler, setScenario: (fn) => { scenario = fn; } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// One fully-successful groupable file op (read / list_dir).
|
|
103
|
+
function fileOp(cb, tag, path) {
|
|
104
|
+
cb.onToolStart(tag, path, { id: `${tag}-${path}`, attrs: { path } });
|
|
105
|
+
cb.onToolEnd(tag, 'contents', 5, { id: `${tag}-${path}`, attrs: { path }, meta: { bytes: 10 }, error: null });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One fully-successful web op (http_get) — leaves the web group OPEN.
|
|
109
|
+
function webOp(cb, url) {
|
|
110
|
+
cb.onToolStart('http_get', url, { id: `g-${url}`, attrs: { url } });
|
|
111
|
+
cb.onToolEnd('http_get', {}, 120, { id: `g-${url}`, attrs: { url }, meta: { status_code: 200, bytes: 1000 }, error: null });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const commits = (events) => events.filter((e) => e.kind === 'commit');
|
|
115
|
+
const fileSummaries = (events) => commits(events).filter((e) => /file .* explored ×\d+/.test(e.line));
|
|
116
|
+
const webSummaries = (events) => commits(events).filter((e) => / web /.test(e.line) && /source/.test(e.line));
|
|
117
|
+
|
|
118
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// (a) The SCREENSHOT scenario: a group of list/read ops, then a content-bearing
|
|
120
|
+
// INTERMEDIATE narration (the conclusion based on them), then a non-groupable
|
|
121
|
+
// tool (write_file). The group must commit ABOVE the narration.
|
|
122
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
123
|
+
test('(a) group of list ops → intermediate conclusion narration → write_file: group commits ABOVE the narration', async () => {
|
|
124
|
+
const h = harness();
|
|
125
|
+
h.setScenario(async (cb) => {
|
|
126
|
+
cb.onAssistantMessage(''); // empty pre-tool narration — must NOT flush
|
|
127
|
+
fileOp(cb, 'list_dir', '/proj');
|
|
128
|
+
fileOp(cb, 'list_dir', '/proj/src');
|
|
129
|
+
fileOp(cb, 'list_dir', '/proj/test');
|
|
130
|
+
// The conclusion drawn FROM the listings — content-bearing, intermediate (a
|
|
131
|
+
// tool follows). This is what must NOT sit above the listings it summarizes.
|
|
132
|
+
cb.onAssistantMessage('The directory is almost empty.', { terminal: false });
|
|
133
|
+
// A non-groupable effectful tool follows.
|
|
134
|
+
cb.onToolStart('write_file', '/proj/new.js', { id: 'w1', attrs: { path: '/proj/new.js' } });
|
|
135
|
+
cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/proj/new.js' }, meta: { bytes: 3 }, error: null });
|
|
136
|
+
cb.onAssistantMessage('Done.', { terminal: true });
|
|
137
|
+
});
|
|
138
|
+
await h.handler('list a few dirs, conclude, then write');
|
|
139
|
+
|
|
140
|
+
const s = fileSummaries(h.events);
|
|
141
|
+
assert.strictEqual(s.length, 1, 'the three listings collapse to one summary');
|
|
142
|
+
assert.match(s[0].line, /explored ×3/);
|
|
143
|
+
|
|
144
|
+
const iGroup = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
|
|
145
|
+
const iNarration = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'The directory is almost empty.');
|
|
146
|
+
assert.ok(iGroup >= 0 && iNarration >= 0, 'both group and narration present');
|
|
147
|
+
assert.ok(iGroup < iNarration, 'the explored ×3 group commits ABOVE the conclusion narration (the screenshot fix)');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
151
|
+
// (b) SILENT multi-read run — only empty interim narration. Still ONE summary.
|
|
152
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
153
|
+
test('(b) silent multi-read run (empty interim narration) still collapses to ONE explored ×N', async () => {
|
|
154
|
+
const h = harness();
|
|
155
|
+
h.setScenario(async (cb) => {
|
|
156
|
+
cb.onAssistantMessage('');
|
|
157
|
+
fileOp(cb, 'read', '/a.js');
|
|
158
|
+
fileOp(cb, 'read', '/b.js');
|
|
159
|
+
cb.onAssistantMessage('', { terminal: false }); // silent intermediate — must NOT flush
|
|
160
|
+
fileOp(cb, 'read', '/c.js');
|
|
161
|
+
fileOp(cb, 'read', '/d.js');
|
|
162
|
+
cb.onAssistantMessage('', { terminal: false }); // silent intermediate — must NOT flush
|
|
163
|
+
fileOp(cb, 'read', '/e.js');
|
|
164
|
+
cb.onAssistantMessage('Read everything.', { terminal: true });
|
|
165
|
+
});
|
|
166
|
+
await h.handler('silent multi-read run');
|
|
167
|
+
|
|
168
|
+
const s = fileSummaries(h.events);
|
|
169
|
+
assert.strictEqual(s.length, 1, 'a silent run collapses to exactly ONE summary across all iterations');
|
|
170
|
+
assert.match(s[0].line, /explored ×5/, 'all five reads counted — empty interim narration did not split the group');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
174
|
+
// (c) CHATTY run — content-bearing narration between reads. Fragments into
|
|
175
|
+
// correctly-ordered sub-groups, each ABOVE its narration.
|
|
176
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
177
|
+
test('(c) chatty run: content-bearing interim narration fragments into correctly-ordered sub-groups', async () => {
|
|
178
|
+
const h = harness();
|
|
179
|
+
h.setScenario(async (cb) => {
|
|
180
|
+
cb.onAssistantMessage('');
|
|
181
|
+
fileOp(cb, 'read', '/a.js');
|
|
182
|
+
fileOp(cb, 'read', '/b.js');
|
|
183
|
+
fileOp(cb, 'read', '/c.js');
|
|
184
|
+
cb.onAssistantMessage('First batch looks fine.', { terminal: false }); // flushes group #1
|
|
185
|
+
fileOp(cb, 'read', '/d.js');
|
|
186
|
+
fileOp(cb, 'read', '/e.js');
|
|
187
|
+
fileOp(cb, 'read', '/f.js');
|
|
188
|
+
cb.onAssistantMessage('Second batch too.', { terminal: true }); // flushes group #2
|
|
189
|
+
});
|
|
190
|
+
await h.handler('chatty multi-read run');
|
|
191
|
+
|
|
192
|
+
const s = fileSummaries(h.events);
|
|
193
|
+
assert.strictEqual(s.length, 2, 'two content-bearing narrations → two fragments');
|
|
194
|
+
assert.ok(s.every((e) => /explored ×3/.test(e.line)), 'each fragment is explored ×3');
|
|
195
|
+
|
|
196
|
+
const iSum1 = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
|
|
197
|
+
const iNarr1 = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'First batch looks fine.');
|
|
198
|
+
const iSum2 = h.events.findIndex((e, idx) => idx > iSum1 && e.kind === 'commit' && /explored ×3/.test(e.line));
|
|
199
|
+
const iNarr2 = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'Second batch too.');
|
|
200
|
+
assert.ok(iSum1 < iNarr1, 'fragment #1 above its narration');
|
|
201
|
+
assert.ok(iNarr1 < iSum2, 'fragment #2 starts after narration #1');
|
|
202
|
+
assert.ok(iSum2 < iNarr2, 'fragment #2 above its narration');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// (d) STREAMED narration (token path) — the group must commit ABOVE the first
|
|
207
|
+
// streamed narration line. This is the load-bearing case: streamToken commits
|
|
208
|
+
// the header + line to scrollback BEFORE onAssistantMessage, so the flush must
|
|
209
|
+
// happen at streaming-START (onToken), not at onAssistantMessage.
|
|
210
|
+
// showThink:true disables the implicit-think buffer so onToken streams live.
|
|
211
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
212
|
+
test('(d) streamed content-bearing narration: group commits ABOVE the first narration line', async () => {
|
|
213
|
+
const h = harness({ showThink: true });
|
|
214
|
+
h.setScenario(async (cb) => {
|
|
215
|
+
fileOp(cb, 'list_dir', '/proj');
|
|
216
|
+
fileOp(cb, 'list_dir', '/proj/src');
|
|
217
|
+
fileOp(cb, 'list_dir', '/proj/test');
|
|
218
|
+
// Narration STREAMS in token-by-token (what really happens). The very first
|
|
219
|
+
// content token must flush the open group first.
|
|
220
|
+
for (const tok of ['The ', 'directory ', 'is ', 'almost ', 'empty.', '\n']) cb.onToken(tok);
|
|
221
|
+
cb.onAssistantMessage('The directory is almost empty.', { terminal: true });
|
|
222
|
+
});
|
|
223
|
+
await h.handler('stream a conclusion after listing');
|
|
224
|
+
|
|
225
|
+
const s = fileSummaries(h.events);
|
|
226
|
+
assert.strictEqual(s.length, 1, 'one summary');
|
|
227
|
+
assert.match(s[0].line, /explored ×3/);
|
|
228
|
+
|
|
229
|
+
const iGroup = h.events.findIndex((e) => e.kind === 'commit' && /explored ×3/.test(e.line));
|
|
230
|
+
const iFirstNarration = h.events.findIndex((e) => e.kind === 'narration');
|
|
231
|
+
assert.ok(iGroup >= 0 && iFirstNarration >= 0, 'group and streamed narration both present');
|
|
232
|
+
assert.ok(iGroup < iFirstNarration, 'the group commits ABOVE the FIRST streamed narration line (streaming-start flush)');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
236
|
+
// (e) DOUBLE-FLUSH idempotency — the streaming-start flush, then the
|
|
237
|
+
// onAssistantMessage flush, then the turn-end finally flush all call flush();
|
|
238
|
+
// the group commits EXACTLY once.
|
|
239
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
240
|
+
test('(e) double-flush idempotency: streaming-start + onAssistantMessage + finally → exactly one commit', async () => {
|
|
241
|
+
const h = harness({ showThink: true });
|
|
242
|
+
h.setScenario(async (cb) => {
|
|
243
|
+
fileOp(cb, 'read', '/a.js');
|
|
244
|
+
fileOp(cb, 'read', '/b.js');
|
|
245
|
+
fileOp(cb, 'read', '/c.js');
|
|
246
|
+
for (const tok of ['All ', 'good.', '\n']) cb.onToken(tok); // flush #1 (streaming-start)
|
|
247
|
+
cb.onAssistantMessage('All good.', { terminal: true }); // flush #2 (no-op) + finally flush (no-op)
|
|
248
|
+
});
|
|
249
|
+
await h.handler('one group, many flush opportunities');
|
|
250
|
+
|
|
251
|
+
const s = fileSummaries(h.events);
|
|
252
|
+
assert.strictEqual(s.length, 1, 'the group committed EXACTLY once despite multiple flush() calls');
|
|
253
|
+
assert.match(s[0].line, /explored ×3/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
257
|
+
// (f) WEB group parity — the open web group also flushes before content-bearing
|
|
258
|
+
// narration, symmetrically with the file group.
|
|
259
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
260
|
+
test('(f) web group flushes before content-bearing narration (symmetry with the file group)', async () => {
|
|
261
|
+
const h = harness();
|
|
262
|
+
h.setScenario(async (cb) => {
|
|
263
|
+
cb.onAssistantMessage('');
|
|
264
|
+
webOp(cb, 'https://a.example');
|
|
265
|
+
webOp(cb, 'https://b.example');
|
|
266
|
+
// Content-bearing intermediate narration must flush the web group above it.
|
|
267
|
+
cb.onAssistantMessage('Both pages confirm the API shape.', { terminal: false });
|
|
268
|
+
cb.onToolStart('write_file', '/notes.md', { id: 'w1', attrs: { path: '/notes.md' } });
|
|
269
|
+
cb.onToolEnd('write_file', 'ok', 4, { id: 'w1', attrs: { path: '/notes.md' }, meta: { bytes: 3 }, error: null });
|
|
270
|
+
cb.onAssistantMessage('Done.', { terminal: true });
|
|
271
|
+
});
|
|
272
|
+
await h.handler('fetch two pages, conclude, then write');
|
|
273
|
+
|
|
274
|
+
const w = webSummaries(h.events);
|
|
275
|
+
assert.strictEqual(w.length, 1, 'the two fetches collapse to one web summary');
|
|
276
|
+
const iWeb = h.events.findIndex((e) => e.kind === 'commit' && / web /.test(e.line) && /source/.test(e.line));
|
|
277
|
+
const iNarration = h.events.findIndex((e) => e.kind === 'answer' && e.content === 'Both pages confirm the API shape.');
|
|
278
|
+
assert.ok(iWeb >= 0 && iNarration >= 0, 'web summary and narration present');
|
|
279
|
+
assert.ok(iWeb < iNarration, 'the web summary commits ABOVE the content-bearing narration');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// (g) WHITESPACE-only interim narration does NOT flush (silent collapse preserved
|
|
284
|
+
// even when pure streaming artifacts arrive between reads).
|
|
285
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
286
|
+
test('(g) whitespace-only interim narration does NOT flush — silent collapse preserved', async () => {
|
|
287
|
+
const h = harness({ showThink: true });
|
|
288
|
+
let commitsAfterWhitespace = -1;
|
|
289
|
+
h.setScenario(async (cb) => {
|
|
290
|
+
fileOp(cb, 'read', '/a.js');
|
|
291
|
+
fileOp(cb, 'read', '/b.js');
|
|
292
|
+
// Pure streaming artifacts between reads — whitespace tokens + a whitespace
|
|
293
|
+
// finalize. NONE of these may flush the open group (token.trim() is empty so
|
|
294
|
+
// the streaming-start gate is skipped; the onAssistantMessage gate sees no
|
|
295
|
+
// content). The group stays open and uncommitted.
|
|
296
|
+
cb.onToken(' ');
|
|
297
|
+
cb.onToken('\n');
|
|
298
|
+
cb.onAssistantMessage(' ', { terminal: false });
|
|
299
|
+
commitsAfterWhitespace = commits(h.events).length; // snapshot: must be 0 — nothing flushed
|
|
300
|
+
fileOp(cb, 'read', '/c.js');
|
|
301
|
+
cb.onAssistantMessage('Read all three.', { terminal: true }); // terminal → the single flush
|
|
302
|
+
});
|
|
303
|
+
await h.handler('whitespace artifacts between reads');
|
|
304
|
+
|
|
305
|
+
assert.strictEqual(commitsAfterWhitespace, 0, 'whitespace interim narration committed NOTHING — the group was not flushed');
|
|
306
|
+
const s = fileSummaries(h.events);
|
|
307
|
+
assert.strictEqual(s.length, 1, 'whitespace interim narration did NOT split the group');
|
|
308
|
+
assert.match(s[0].line, /explored ×3/, 'all three reads collapsed into ONE summary');
|
|
309
|
+
});
|
|
@@ -354,3 +354,56 @@ test('native plan mode OFF (approval): the same native mutating tool executes',
|
|
|
354
354
|
await mock.close();
|
|
355
355
|
}
|
|
356
356
|
});
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// 7. P1 — native rail does NOT run textual command heuristics. A finish=stop
|
|
360
|
+
// turn whose prose contains an illustrative ```bash block (no tool tag) must
|
|
361
|
+
// yield ZERO tool calls (the incident: a hung `su nobody` + placeholder
|
|
362
|
+
// examples were executed). EXPLICIT tool tags still dispatch on the native
|
|
363
|
+
// rail. (test-model has no profile → isNativeToolsActive defaults true, the
|
|
364
|
+
// same native-rail assumption the structured tests above rely on.)
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
test('P1 native rail: illustrative ```bash block in a final answer is NOT executed (heuristic skipped)', async () => {
|
|
368
|
+
const mock = await startMockLLM();
|
|
369
|
+
// Single stop-turn: prose with a fenced example, no tool tag. On the native
|
|
370
|
+
// rail the bare-fence heuristic is suppressed → zero tool calls → final answer.
|
|
371
|
+
mock.replyWith('To drop privileges you could run:\n```bash\nsu nobody\necho "$SECRET_TOKEN"\n```\nBut do not run that here.');
|
|
372
|
+
try {
|
|
373
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
374
|
+
const { ev, cb } = collector();
|
|
375
|
+
const messages = [{ role: 'user', content: 'how do I drop privileges?' }];
|
|
376
|
+
const { metrics } = await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
377
|
+
|
|
378
|
+
assert.strictEqual(ev.tools.length, 0, 'NO shell command extracted/executed from the illustrative fence');
|
|
379
|
+
assert.deepStrictEqual(asks, [], 'no permission gate consulted — nothing dispatched');
|
|
380
|
+
assert.strictEqual(metrics.turns.length, 1, 'single turn — treated as a completed text answer');
|
|
381
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && /su nobody/.test(m.content)),
|
|
382
|
+
'the prose (including the example) is recorded as the final answer, not run');
|
|
383
|
+
assert.strictEqual(mock.pending(), 0, 'loop ended on the first reply (no tool round-trip)');
|
|
384
|
+
} finally {
|
|
385
|
+
await mock.close();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('P1 native rail: an EXPLICIT <shell> tag in content STILL dispatches and executes', async () => {
|
|
390
|
+
const mock = await startMockLLM();
|
|
391
|
+
mock.replyWith('<shell>echo NATIVE_XML_TAG_OUT</shell>');
|
|
392
|
+
mock.replyWith('Ran it.');
|
|
393
|
+
try {
|
|
394
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
395
|
+
const { ev, cb } = collector();
|
|
396
|
+
const messages = [{ role: 'user', content: 'run echo' }];
|
|
397
|
+
const { metrics } = await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
398
|
+
|
|
399
|
+
assert.strictEqual(ev.tools.length, 1, 'explicit tag dispatched on the native rail');
|
|
400
|
+
assert.strictEqual(ev.tools[0].tag, 'shell');
|
|
401
|
+
assert.deepStrictEqual(asks, [{ actionType: 'shell', tag: 'exec' }], 'shell gate fired for the explicit tag');
|
|
402
|
+
const toolResult = messages.find((m) => m.role === 'user' && /Tool execution results/.test(m.content));
|
|
403
|
+
assert.ok(toolResult && /NATIVE_XML_TAG_OUT/.test(toolResult.content), 'command stdout flowed back');
|
|
404
|
+
assert.strictEqual(metrics.turns.length, 2, 'tool turn + final turn');
|
|
405
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'Ran it.'));
|
|
406
|
+
} finally {
|
|
407
|
+
await mock.close();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Live token-by-token narration on the NATIVE tool-call rail, gated by a safety
|
|
4
|
+
// signal so implicit reasoning is NEVER streamed (leaked).
|
|
5
|
+
//
|
|
6
|
+
// These tests drive the REAL createTurnHandler callbacks (chat-turn.js) wired to
|
|
7
|
+
// the REAL ChatHistory (chat-history.js, with _commit captured), simulating the
|
|
8
|
+
// per-iteration callback order agent.js + api.js produce:
|
|
9
|
+
//
|
|
10
|
+
// onRequestSent() reset gate + safety signals
|
|
11
|
+
// onReasoningStart() (only when delta.reasoning_content seen)
|
|
12
|
+
// onStreamStart(nativeRail, inlineR) first content token — rail + flag (signal b)
|
|
13
|
+
// onToken(t) … each delta.content token
|
|
14
|
+
// onAssistantMessage(clean,{terminal}) finalize the turn
|
|
15
|
+
//
|
|
16
|
+
// The gate (chat-turn.js onToken) opens eagerly ONLY when nativeRail AND
|
|
17
|
+
// (reasoningSeen OR inline_reasoning:false). Otherwise it keeps the buffered-
|
|
18
|
+
// until-boundary behavior (the no-leak fallback, also the entire XML-rail path).
|
|
19
|
+
|
|
20
|
+
const { test } = require('node:test');
|
|
21
|
+
const assert = require('node:assert');
|
|
22
|
+
|
|
23
|
+
const { createTurnHandler } = require('../lib/commands/chat-turn');
|
|
24
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
25
|
+
const { stripAnsi } = require('../lib/ui/utils');
|
|
26
|
+
const { normalizeConfig } = require('../lib/config');
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Config: inline_reasoning (signal b) normalizes to an explicit boolean only.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
test('inline_reasoning persists only as an explicit boolean on a models[] profile', () => {
|
|
32
|
+
const base = { api_base: 'http://x', api_key: 'k', model: 'm' };
|
|
33
|
+
const norm = (extra) => normalizeConfig({ models: [{ ...base, ...extra }] }).models[0];
|
|
34
|
+
|
|
35
|
+
assert.strictEqual('inline_reasoning' in norm({}), false, 'unset by default → assume might inline (safe)');
|
|
36
|
+
assert.strictEqual(norm({ inline_reasoning: false }).inline_reasoning, false, 'explicit false persists');
|
|
37
|
+
assert.strictEqual(norm({ inline_reasoning: true }).inline_reasoning, true, 'explicit true persists');
|
|
38
|
+
// Non-boolean junk is dropped (stays unset → safe default), never coerced.
|
|
39
|
+
assert.strictEqual('inline_reasoning' in norm({ inline_reasoning: 'false' }), false, 'string is dropped, not coerced');
|
|
40
|
+
assert.strictEqual('inline_reasoning' in norm({ inline_reasoning: 0 }), false, 'number is dropped, not coerced');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Build a harness around the REAL createTurnHandler + REAL ChatHistory.
|
|
44
|
+
// `streamed` — tokens the gate passed live to chatHistory.streamToken.
|
|
45
|
+
// `committed` — everything that reached scrollback (via the captured _commit).
|
|
46
|
+
// `statuses` — statusBar.update (state,label) pairs, to assert the transition.
|
|
47
|
+
function harness(opts = {}) {
|
|
48
|
+
const streamed = [];
|
|
49
|
+
const committed = [];
|
|
50
|
+
const statuses = [];
|
|
51
|
+
|
|
52
|
+
const chatHistory = new ChatHistory();
|
|
53
|
+
chatHistory._commit = (t) => committed.push(t);
|
|
54
|
+
chatHistory._setDetail = () => {};
|
|
55
|
+
chatHistory._commitDetail = (t) => { if (t) committed.push(t); };
|
|
56
|
+
const origStream = chatHistory.streamToken.bind(chatHistory);
|
|
57
|
+
chatHistory.streamToken = (t) => { streamed.push(t); origStream(t); };
|
|
58
|
+
|
|
59
|
+
const statusBar = {
|
|
60
|
+
update: (state, label) => statuses.push([state, label]),
|
|
61
|
+
onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {},
|
|
62
|
+
};
|
|
63
|
+
const inputField = {
|
|
64
|
+
on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {},
|
|
65
|
+
};
|
|
66
|
+
const writerModule = {
|
|
67
|
+
startActivity() {}, updateActivity() {}, endActivity() {}, scrollback() {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let scenario = async () => {};
|
|
71
|
+
const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
|
|
72
|
+
await scenario(loopOpts.callbacks);
|
|
73
|
+
return { messages, metrics: { turns: [] }, withheldActions: [] };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ctx = {
|
|
77
|
+
inputField, statusBar, chatHistory, writerModule, runAgentLoop,
|
|
78
|
+
getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
|
|
79
|
+
approxTokens: () => 0,
|
|
80
|
+
resolveCommand: () => null,
|
|
81
|
+
opts: { showThink: !!opts.showThink },
|
|
82
|
+
TAG_REGISTRY: {},
|
|
83
|
+
collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
|
|
84
|
+
activateNavCapture() {}, finalizeListMsg() {},
|
|
85
|
+
createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
|
|
86
|
+
messages: [], currentModel: 'm', debugMode: false, pendingImages: [],
|
|
87
|
+
chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handler = createTurnHandler(ctx, {});
|
|
91
|
+
return { streamed, committed, statuses, handler, setScenario: (fn) => { scenario = fn; } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Simulate one streaming iteration in the exact callback order agent.js drives.
|
|
95
|
+
function iteration(cb, { native, inlineReasoning, reasoningSeen, tokens, finalContent }) {
|
|
96
|
+
cb.onRequestSent();
|
|
97
|
+
if (reasoningSeen) cb.onReasoningStart(); // api.js wrappedOnReasoning (signal a)
|
|
98
|
+
cb.onStreamStart(native, inlineReasoning); // first content token (rail + signal b)
|
|
99
|
+
for (const t of tokens) cb.onToken(t);
|
|
100
|
+
cb.onAssistantMessage(finalContent, { terminal: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function committedText(committed) { return stripAnsi(committed.join('')); }
|
|
104
|
+
function countHeaders(committed) {
|
|
105
|
+
return (committedText(committed).match(/▸ AI-agent/g) || []).length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Case 1 — native rail + reasoning_content seen → narration streams LIVE
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
test('native rail + reasoning_content seen: narration streams live (gate opens before finalize)', async () => {
|
|
112
|
+
const h = harness();
|
|
113
|
+
h.setScenario(async (cb) => {
|
|
114
|
+
iteration(cb, {
|
|
115
|
+
native: true, inlineReasoning: undefined, reasoningSeen: true,
|
|
116
|
+
tokens: ['Hello', ' world'], finalContent: 'Hello world',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
await h.handler('hi');
|
|
120
|
+
|
|
121
|
+
assert.deepStrictEqual(h.streamed, ['Hello', ' world'], 'both narration tokens streamed live');
|
|
122
|
+
// Status bar transitioned to streaming on eager-open (not stuck on Thinking).
|
|
123
|
+
assert.ok(h.statuses.some(([s, l]) => s === 'streaming' && l === 'Streaming response'),
|
|
124
|
+
'status bar transitions to streaming on eager-open');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Case 2 — native rail + inline_reasoning:false → narration streams from token 1
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
test('native rail + inline_reasoning:false: narration streams live from token 1', async () => {
|
|
131
|
+
const h = harness();
|
|
132
|
+
h.setScenario(async (cb) => {
|
|
133
|
+
iteration(cb, {
|
|
134
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
135
|
+
tokens: ['The', ' answer'], finalContent: 'The answer',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
await h.handler('hi');
|
|
139
|
+
|
|
140
|
+
assert.deepStrictEqual(h.streamed, ['The', ' answer'], 'streams live from the first token, no reasoning_content needed');
|
|
141
|
+
assert.ok(h.statuses.some(([s, l]) => s === 'streaming' && l === 'Streaming response'),
|
|
142
|
+
'status bar transitions to streaming on eager-open');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Case 3 — native rail, NO signal, bare-text-then-orphan-</think>:
|
|
147
|
+
// reasoning stays HIDDEN (no leak); narration after </think> streams.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
test('native rail + NO signal + implicit think: reasoning hidden, post-</think> narration streams', async () => {
|
|
150
|
+
const h = harness();
|
|
151
|
+
h.setScenario(async (cb) => {
|
|
152
|
+
iteration(cb, {
|
|
153
|
+
native: true, inlineReasoning: undefined, reasoningSeen: false,
|
|
154
|
+
tokens: ['Let', ' me', ' think', '</think>', 'The answer', ' is 42'],
|
|
155
|
+
finalContent: 'The answer is 42',
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
await h.handler('hi');
|
|
159
|
+
|
|
160
|
+
// The bare reasoning tokens were buffered+discarded — never streamed, never committed.
|
|
161
|
+
assert.deepStrictEqual(h.streamed, ['The answer', ' is 42'], 'only post-</think> narration streams');
|
|
162
|
+
const text = committedText(h.committed);
|
|
163
|
+
for (const leak of ['Let', 'me', 'think']) {
|
|
164
|
+
assert.ok(!text.includes(leak), `reasoning token ${JSON.stringify(leak)} must NOT reach scrollback (no leak)`);
|
|
165
|
+
}
|
|
166
|
+
assert.ok(text.includes('The answer is 42'), 'narration is rendered');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Case 4 — XML rail (nativeRail false): identical to old behavior even WITH
|
|
171
|
+
// reasoning_content seen. The eager-open guard excludes the XML rail.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
test('XML rail: buffered-until-</think> behavior unchanged even with reasoning seen (regression guard)', async () => {
|
|
174
|
+
const h = harness();
|
|
175
|
+
h.setScenario(async (cb) => {
|
|
176
|
+
iteration(cb, {
|
|
177
|
+
native: false, inlineReasoning: false, reasoningSeen: true,
|
|
178
|
+
tokens: ['secret', ' reasoning', '</think>', 'visible answer'],
|
|
179
|
+
finalContent: 'visible answer',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
await h.handler('hi');
|
|
183
|
+
|
|
184
|
+
assert.deepStrictEqual(h.streamed, ['visible answer'], 'XML rail buffers leading text despite signals');
|
|
185
|
+
const text = committedText(h.committed);
|
|
186
|
+
assert.ok(!text.includes('secret'), 'XML rail does not leak buffered reasoning');
|
|
187
|
+
assert.ok(text.includes('visible answer'), 'XML rail narration after </think> still renders');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Case 5 — finalize after a live stream does NOT double-print (one AI header).
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
test('finalize after live stream: no double-print (single AI bubble, no _buildAI re-synthesis)', async () => {
|
|
194
|
+
const h = harness();
|
|
195
|
+
h.setScenario(async (cb) => {
|
|
196
|
+
iteration(cb, {
|
|
197
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
198
|
+
tokens: ['unique-token-α'], finalContent: 'unique-token-α',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
await h.handler('hi');
|
|
202
|
+
|
|
203
|
+
const text = committedText(h.committed);
|
|
204
|
+
assert.strictEqual(countHeaders(h.committed), 1, 'exactly one ▸ AI-agent header (no re-synthesized bubble)');
|
|
205
|
+
assert.strictEqual((text.match(/unique-token-α/g) || []).length, 1, 'narration committed exactly once');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Case 6 (P2 regression) — native rail, reasoning_content seen (eager-open),
|
|
210
|
+
// content carries a STRAY inline </think>. MiniMax emits reasoning via BOTH
|
|
211
|
+
// reasoning_content AND an orphan </think> terminator in content; the
|
|
212
|
+
// StreamParser passes that orphan tag through verbatim (its `/think` form is not
|
|
213
|
+
// a TAG_REGISTRY key). The eager-open path must DROP it (not stream it) while the
|
|
214
|
+
// surrounding narration streams live. Regression from 938f583: the eager-open
|
|
215
|
+
// fell through to streamToken and skipped the orphan-drop guard for every token.
|
|
216
|
+
// (f) The committed scrollback — what persists/replays — stays clean too.
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
test('native rail + reasoning_content + inline orphan </think>: stray tag dropped, narration streams (P2)', async () => {
|
|
219
|
+
const h = harness();
|
|
220
|
+
h.setScenario(async (cb) => {
|
|
221
|
+
iteration(cb, {
|
|
222
|
+
native: true, inlineReasoning: undefined, reasoningSeen: true,
|
|
223
|
+
tokens: ['Here', ' is', '</think>', ' the answer'],
|
|
224
|
+
finalContent: 'Here is the answer',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
await h.handler('hi');
|
|
228
|
+
|
|
229
|
+
assert.deepStrictEqual(h.streamed, ['Here', ' is', ' the answer'],
|
|
230
|
+
'orphan </think> dropped from the live stream; surrounding narration streamed live');
|
|
231
|
+
const text = committedText(h.committed);
|
|
232
|
+
assert.ok(!text.includes('</think>'), 'the stray </think> never reaches scrollback (live + persisted clean)');
|
|
233
|
+
assert.ok(text.includes('Here is the answer'), 'narration is rendered');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Every closing reasoning tag the StreamParser emits raw must drop on the
|
|
237
|
+
// eager-open path — for the WHOLE stream, not just the first token. The set
|
|
238
|
+
// matches the registered visual tags (think/reasoning/reflection/plan); their
|
|
239
|
+
// `/tag` closing form is never a TAG_REGISTRY key, so all stream verbatim.
|
|
240
|
+
test('native rail eager-open: orphan think/reasoning/reflection/plan close tags all dropped (P2)', async () => {
|
|
241
|
+
for (const tag of ['think', 'reasoning', 'reflection', 'plan']) {
|
|
242
|
+
const h = harness();
|
|
243
|
+
h.setScenario(async (cb) => {
|
|
244
|
+
iteration(cb, {
|
|
245
|
+
native: true, inlineReasoning: false, reasoningSeen: false,
|
|
246
|
+
tokens: ['ok ', `</${tag}>`, 'done'],
|
|
247
|
+
finalContent: 'ok done',
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
await h.handler('hi');
|
|
251
|
+
assert.deepStrictEqual(h.streamed, ['ok ', 'done'], `</${tag}> dropped on the eager-open path`);
|
|
252
|
+
assert.ok(!committedText(h.committed).includes(`</${tag}>`), `</${tag}> never committed`);
|
|
253
|
+
}
|
|
254
|
+
});
|