@semalt-ai/code 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +187 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +272 -49
- 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 +236 -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 +522 -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/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/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,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Output Refactor — Phase 6c-i: persist web-op cores, with ZERO visible change.
|
|
4
|
+
//
|
|
5
|
+
// Web tools (web_search/http_get) are intercepted in chat-turn.js BEFORE they
|
|
6
|
+
// become a normal ToolOperation descriptor (they collapse into the live web
|
|
7
|
+
// summary). Before 6c-i the interception returned `undefined`, so the agent
|
|
8
|
+
// loop's `displayCore || null` push stored a `null` slot and the web op fell to
|
|
9
|
+
// the legacy whole-blob / summarizeToolResult fallback on replay. 6c-i makes the
|
|
10
|
+
// interception return a dedicated web-op core `{v:1,kind:'web',…}` that the slot
|
|
11
|
+
// now carries on BOTH rails — while every replay reader is taught to treat a
|
|
12
|
+
// web-core as "no descriptor → fallback". Net contract: replay output is
|
|
13
|
+
// BYTE-IDENTICAL to today. Aggregation lands in 6c-ii, not here.
|
|
14
|
+
//
|
|
15
|
+
// These tests pin:
|
|
16
|
+
// 1. POSITIVE — serializeWebOp sources fields from ctx; the core lands in the
|
|
17
|
+
// slot on the native {role:'tool'} message AND the XML `_display[]` array.
|
|
18
|
+
// 2. 6c-ii FLIP (headline) — replay of a native web message and of an XML blob
|
|
19
|
+
// containing a web op now AGGREGATES the web-core(s) into the committed
|
|
20
|
+
// `✓ web · …` summary, byte-identical to the live committed line. (These were
|
|
21
|
+
// 6c-i "no-op replay" cases; 6c-ii deliberately makes web activity visible on
|
|
22
|
+
// replay. Persistence/shape/inv.1/live-tracker pins below are unchanged.)
|
|
23
|
+
// 3. INV.1 — `content` byte-identical; the web-core is never folded into content.
|
|
24
|
+
// 4. ANTI-PING-PONG — fresh live web display unchanged; 6a/6b normal-tool parity
|
|
25
|
+
// and Phase 1 fresh-render still byte-identical.
|
|
26
|
+
|
|
27
|
+
const { test } = require('node:test');
|
|
28
|
+
const assert = require('node:assert');
|
|
29
|
+
|
|
30
|
+
// Force a colour-capable env so byte comparisons are stable (node:test runs each
|
|
31
|
+
// file in its own process — no leak to other suites). Mirrors the 6a/6b suites.
|
|
32
|
+
process.stdout.isTTY = true;
|
|
33
|
+
delete process.env.NO_COLOR;
|
|
34
|
+
|
|
35
|
+
const { buildToolOperation, serializeOperation, descriptorFromStored } = require('../lib/ui/tool-operation');
|
|
36
|
+
const { renderOperation } = require('../lib/ui/render-operation');
|
|
37
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
38
|
+
const { createChatSession } = require('../lib/commands/chat-session');
|
|
39
|
+
const {
|
|
40
|
+
serializeWebOp,
|
|
41
|
+
isWebCore,
|
|
42
|
+
aggregateWebOps,
|
|
43
|
+
webSummaryText,
|
|
44
|
+
createWebActivityTracker,
|
|
45
|
+
} = require('../lib/ui/web-activity');
|
|
46
|
+
|
|
47
|
+
const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
48
|
+
const CFG = { diff_max_lines: 50, shell_preview_lines: 5 };
|
|
49
|
+
|
|
50
|
+
// A normal (non-web) fixture used to prove a MIXED blob (normal + web) still
|
|
51
|
+
// drops to the whole-blob summary — i.e. the gate fails on the web slot.
|
|
52
|
+
const EDIT_DIFF = buildToolOperation({
|
|
53
|
+
id: 'tool-1', tag: 'edit_file', arg: 'lib/x.js', attrs: { path: 'lib/x.js' },
|
|
54
|
+
status: 'ok', durationMs: 12, diff: { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Tool ctx objects exactly as onToolEnd receives them: { attrs, meta, error }.
|
|
58
|
+
// web_search with a query + a 200/bytes meta; http_get error case with a url and
|
|
59
|
+
// a transport error (no status/bytes).
|
|
60
|
+
const SEARCH_CTX = { attrs: { query: 'коррупционные скандалы 2024' }, meta: { status_code: 200, bytes: 4096 }, error: null };
|
|
61
|
+
const FETCH_ERR_CTX = { attrs: { url: 'https://example.com/blocked' }, meta: null, error: { message: 'Request timeout' } };
|
|
62
|
+
|
|
63
|
+
// ── Faithful models of the agent.js per-rail persistence of onToolEnd's return.
|
|
64
|
+
// Native: _nativeToolMessage attaches `_display` only when truthy (agent.js:352-355),
|
|
65
|
+
// receiving the already-`|| null`-ed slot. XML: messages.push({…, _display:
|
|
66
|
+
// displayCores.slice()}) with each slot = `displayCore || null` (agent.js:1959-1963).
|
|
67
|
+
function nativeToolMsg(content, core) {
|
|
68
|
+
const slot = core || null; // the agent loop's `displayCore || null` push
|
|
69
|
+
const msg = { role: 'tool', content };
|
|
70
|
+
if (slot) msg._display = slot; // _nativeToolMessage: attach only when truthy
|
|
71
|
+
return msg;
|
|
72
|
+
}
|
|
73
|
+
function xmlBlob(results, cores) {
|
|
74
|
+
const m = {
|
|
75
|
+
role: 'user',
|
|
76
|
+
content: `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
|
|
77
|
+
};
|
|
78
|
+
if (cores !== undefined) m._display = cores.map((c) => c || null); // `displayCore || null` per slot
|
|
79
|
+
return m;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Drive the REAL replay (chat-session.displayLoadedMessages) over one loaded
|
|
83
|
+
// message and capture exactly what is committed to scrollback.
|
|
84
|
+
function replay(loadedMessage, cfg) {
|
|
85
|
+
const ch = new ChatHistory();
|
|
86
|
+
const out = [];
|
|
87
|
+
ch._commit = (t) => out.push(t);
|
|
88
|
+
const session = createChatSession({ chatHistory: ch, getConfig: () => cfg || CFG });
|
|
89
|
+
session.displayLoadedMessages([loadedMessage]);
|
|
90
|
+
return out.join('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// 1. POSITIVE — serializeWebOp sources fields from ctx, and the core lands in the
|
|
95
|
+
// slot on BOTH rails (guards web-op persistence on native + XML).
|
|
96
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
97
|
+
test('serializeWebOp: sources query/status/bytes (search) and url/error (fetch) from ctx, flat for aggregateWebOps (guards core shape)', () => {
|
|
98
|
+
const search = serializeWebOp(SEARCH_CTX, 'web_search', 300);
|
|
99
|
+
assert.deepStrictEqual(search, {
|
|
100
|
+
v: 1, kind: 'web', tag: 'web_search',
|
|
101
|
+
query: 'коррупционные скандалы 2024', url: undefined,
|
|
102
|
+
status: 200, bytes: 4096, error: undefined, durationMs: 300,
|
|
103
|
+
});
|
|
104
|
+
const fetch = serializeWebOp(FETCH_ERR_CTX, 'http_get', 50);
|
|
105
|
+
assert.deepStrictEqual(fetch, {
|
|
106
|
+
v: 1, kind: 'web', tag: 'http_get',
|
|
107
|
+
query: undefined, url: 'https://example.com/blocked',
|
|
108
|
+
status: undefined, bytes: undefined, error: 'Request timeout', durationMs: 50,
|
|
109
|
+
});
|
|
110
|
+
// The flat fields are exactly what aggregateWebOps reads (so 6c-ii can feed
|
|
111
|
+
// these cores directly): 1 search query, and the errored fetch shows as blocked.
|
|
112
|
+
const text = webSummaryText(aggregateWebOps([search, fetch]));
|
|
113
|
+
assert.match(text, /search "коррупционные/);
|
|
114
|
+
assert.match(text, /0 sources read/);
|
|
115
|
+
assert.match(text, /1 blocked/);
|
|
116
|
+
// Both are recognized as web-cores; a normal descriptor core is NOT.
|
|
117
|
+
assert.ok(isWebCore(search) && isWebCore(fetch), 'web-cores recognized');
|
|
118
|
+
assert.ok(!isWebCore(serializeOperation(EDIT_DIFF)), 'a normal descriptor core is not a web-core');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('persistence: the web-op core lands in the slot on the native {role:tool} message AND the XML _display[] array (guards both rails)', () => {
|
|
122
|
+
const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
|
|
123
|
+
// Native: truthy core → attached verbatim as `_display`.
|
|
124
|
+
const native = nativeToolMsg('web search "…" (5 results)', core);
|
|
125
|
+
assert.deepStrictEqual(native._display, core, 'native {role:tool}._display carries the web-core');
|
|
126
|
+
// XML: the slot that used to be `null` now holds the web-core.
|
|
127
|
+
const blob = xmlBlob(['edited lib/x.js', 'fetched'], [serializeOperation(EDIT_DIFF), core]);
|
|
128
|
+
assert.deepStrictEqual(blob._display[1], core, 'XML _display[] slot carries the web-core (no longer null)');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
132
|
+
// 2. 6c-ii FLIP (headline visible change) — replay now AGGREGATES web-cores into
|
|
133
|
+
// the committed `✓ web · …` summary instead of the legacy fallback. These two
|
|
134
|
+
// were the 6c-i "no-op replay" cases; 6c-ii deliberately makes them visible.
|
|
135
|
+
// Full oracle-parity coverage (interleaving, cross-iteration, both rails) lives
|
|
136
|
+
// in test/replay-web-aggregate.test.js — these pin the persist-file's own rails.
|
|
137
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
// Drive the live tracker over a single op and return its committed summary line —
|
|
140
|
+
// the byte-exact oracle a replayed web summary must reproduce.
|
|
141
|
+
function liveOracle(tag, input, ctx, durationMs) {
|
|
142
|
+
const frames = [];
|
|
143
|
+
const tracker = createWebActivityTracker({ writerModule: {
|
|
144
|
+
startActivity: () => {}, updateActivity: () => {},
|
|
145
|
+
endActivity: (_id, line) => frames.push(line),
|
|
146
|
+
} });
|
|
147
|
+
tracker.start(tag, input);
|
|
148
|
+
tracker.end(tag, 'done', durationMs, ctx);
|
|
149
|
+
tracker.flush();
|
|
150
|
+
return frames[frames.length - 1];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test('6c-ii: a native web {role:tool} message now replays as the aggregated web summary, byte-identical to live (flip of 6c-i invisibility)', () => {
|
|
154
|
+
const core = serializeWebOp(FETCH_ERR_CTX, 'http_get', 120);
|
|
155
|
+
const oracle = liveOracle('http_get', 'https://example.com/blocked', FETCH_ERR_CTX, 120);
|
|
156
|
+
const committed = replay(nativeToolMsg('web · GET https://example.com\n<page body…>', core));
|
|
157
|
+
assert.strictEqual(committed, oracle, 'native web message replays as the aggregated summary, byte-identical to the live committed line');
|
|
158
|
+
// Not vacuous, and it IS the aggregated web summary (not the legacy fallback).
|
|
159
|
+
assert.match(stripAnsi(committed), /web/);
|
|
160
|
+
assert.match(stripAnsi(committed), /1 blocked/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('6c-ii: an XML blob mixing a normal slot + a web-op slot now renders the normal op per-slot AND aggregates the web op below it (gate passes)', () => {
|
|
164
|
+
const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
|
|
165
|
+
const results = ['edited lib/x.js', 'web search results…'];
|
|
166
|
+
// The normal slot alone replays per-slot (6b path) — same render the mixed blob
|
|
167
|
+
// must reproduce for its non-web slot.
|
|
168
|
+
const editOnly = replay(xmlBlob(results, [serializeOperation(EDIT_DIFF)]));
|
|
169
|
+
const webOracle = liveOracle('web_search', SEARCH_CTX.attrs.query, SEARCH_CTX, 300);
|
|
170
|
+
const mixed = replay(xmlBlob(results, [serializeOperation(EDIT_DIFF), core]));
|
|
171
|
+
assert.strictEqual(mixed, editOnly + webOracle, 'normal slot renders per-slot; the web slot aggregates into the summary committed below it');
|
|
172
|
+
// The non-web slot IS now rendered individually (its diff body appears) — the
|
|
173
|
+
// 6c-i whole-blob fallback no longer applies once every slot is a valid core.
|
|
174
|
+
const diffBody = renderOperation(descriptorFromStored(serializeOperation(EDIT_DIFF)), { mode: 'ansi', phase: 'detail', maxLines: 50 });
|
|
175
|
+
assert.ok(diffBody.length > 0 && mixed.includes(diffBody), 'the non-web slot is rendered per-slot (no whole-blob fallback)');
|
|
176
|
+
// And the web op is no longer hidden: its summary is visible.
|
|
177
|
+
assert.match(stripAnsi(mixed), /web/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
181
|
+
// 3. INV.1 — `content` is byte-identical; the web-core never enters `content`.
|
|
182
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
183
|
+
test('inv.1: attaching the web-core leaves the XML feedback content byte-identical and out of the model-facing string (guards the chokepoint)', () => {
|
|
184
|
+
const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
|
|
185
|
+
const results = ['edited lib/x.js', 'web search results…'];
|
|
186
|
+
const expectedContent = `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`;
|
|
187
|
+
const blob = xmlBlob(results, [serializeOperation(EDIT_DIFF), core]);
|
|
188
|
+
assert.strictEqual(blob.content, expectedContent, 'content equals the results.join-wrapped string, byte-for-byte');
|
|
189
|
+
assert.ok(!blob.content.includes('kind'), 'no web-core framing leaked into content');
|
|
190
|
+
assert.ok(!blob.content.includes('коррупционные'), 'the query lives only in the core, not content');
|
|
191
|
+
// Native rail: the web-core is a sibling key, never inside `content`.
|
|
192
|
+
const native = nativeToolMsg('body', core);
|
|
193
|
+
assert.ok(!native.content.includes('kind') && !native.content.includes('web'), 'native content carries no core framing');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
197
|
+
// 4. ANTI-PING-PONG — live web display unchanged; 6a/6b + Phase 1 still identical.
|
|
198
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
199
|
+
test('anti-ping-pong: the live web tracker render is unchanged — nothing in the live path reads serializeWebOp\'s return (guards live region)', () => {
|
|
200
|
+
// A fake writer captures exactly what the live tracker renders.
|
|
201
|
+
const frames = [];
|
|
202
|
+
const writerModule = {
|
|
203
|
+
startActivity: (_id, fn) => frames.push(stripAnsi(fn(0))),
|
|
204
|
+
updateActivity: (_id, fn) => frames.push(stripAnsi(fn(0))),
|
|
205
|
+
endActivity: (_id, line) => frames.push(stripAnsi(line)),
|
|
206
|
+
};
|
|
207
|
+
const tracker = createWebActivityTracker({ writerModule });
|
|
208
|
+
tracker.start('web_search', 'коррупционные скандалы 2024');
|
|
209
|
+
tracker.end('web_search', 'ok', 300, SEARCH_CTX);
|
|
210
|
+
// The interception ALSO calls serializeWebOp — assert calling it does not alter
|
|
211
|
+
// what the tracker subsequently renders (the return value is persistence-only).
|
|
212
|
+
serializeWebOp(SEARCH_CTX, 'web_search', 300);
|
|
213
|
+
tracker.flush();
|
|
214
|
+
const committed = frames[frames.length - 1];
|
|
215
|
+
assert.match(committed, /web/);
|
|
216
|
+
assert.match(committed, /search/);
|
|
217
|
+
// The committed line is exactly the tracker's own summary — untouched by 6c-i.
|
|
218
|
+
assert.strictEqual(committed, stripAnsi(require('../lib/ui/web-activity').formatWebSummaryLine(aggregateWebOps([
|
|
219
|
+
{ tag: 'web_search', query: 'коррупционные скандалы 2024', status: 200, bytes: 4096 },
|
|
220
|
+
]), { pending: false })));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('anti-ping-pong: native (6a) normal-tool round-trip and Phase 1 fresh-render bytes unchanged (guards no regression)', () => {
|
|
224
|
+
// 6a: a normal descriptor still round-trips byte-identical (the web guard does
|
|
225
|
+
// not touch the non-web path).
|
|
226
|
+
const restored = descriptorFromStored(serializeOperation(EDIT_DIFF));
|
|
227
|
+
assert.strictEqual(
|
|
228
|
+
renderOperation(restored, { mode: 'ansi' }),
|
|
229
|
+
renderOperation(EDIT_DIFF, { mode: 'ansi' }),
|
|
230
|
+
'native rail result line round-trips byte-identical',
|
|
231
|
+
);
|
|
232
|
+
// Phase 1: the canonical fresh shell line bytes are pinned (same oracle as 6b).
|
|
233
|
+
const shellOk = renderOperation(
|
|
234
|
+
buildToolOperation({ status: 'success', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 2300, meta: { exit_code: 0 } }),
|
|
235
|
+
{ mode: 'ansi', phase: 'result' },
|
|
236
|
+
);
|
|
237
|
+
assert.strictEqual(
|
|
238
|
+
shellOk,
|
|
239
|
+
' \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',
|
|
240
|
+
);
|
|
241
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Animated running op row (Output Refactor — Phase 3, Part 2).
|
|
4
|
+
//
|
|
5
|
+
// Before Phase 3 a running tool's glyph was a static dot (●, colored after
|
|
6
|
+
// Phase 2.5 but not moving) and the elapsed meter only advanced as a side
|
|
7
|
+
// effect of the status-bar timers firing. Phase 3 animates the running glyph:
|
|
8
|
+
// the `tool` SPINNER_DEF frames in the category-tinted pending colour, with the
|
|
9
|
+
// frame derived from the elapsed duration so it advances every ~100 ms as the
|
|
10
|
+
// single driver repaints the row with a fresh elapsedMs. The elapsed meter
|
|
11
|
+
// rides the same repaint.
|
|
12
|
+
//
|
|
13
|
+
// These tests exercise the render path the writer's activity region invokes:
|
|
14
|
+
// renderOperation(descriptor, { phase: 'pending' }) → formatToolLine.
|
|
15
|
+
|
|
16
|
+
const { test } = require('node:test');
|
|
17
|
+
const assert = require('node:assert');
|
|
18
|
+
|
|
19
|
+
const { SPINNER_DEFS } = require('../lib/ui/ansi');
|
|
20
|
+
const { formatToolLine } = require('../lib/ui/format');
|
|
21
|
+
const { renderOperation } = require('../lib/ui/render-operation');
|
|
22
|
+
|
|
23
|
+
// Strip SGR so we can inspect the visible glyph/elapsed text.
|
|
24
|
+
function plain(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
|
|
25
|
+
function firstGlyph(s) { return plain(s).trimStart()[0]; }
|
|
26
|
+
|
|
27
|
+
const TOOL_FRAMES = SPINNER_DEFS.tool.frames;
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// The running glyph cycles the tool spinner frames as elapsed advances.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
test('running glyph cycles spinner frames across ticks (elapsed-derived)', () => {
|
|
34
|
+
const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
|
|
35
|
+
// Frame is floor(elapsedMs / 100) % frames.length. Sample one full cycle.
|
|
36
|
+
for (let i = 0; i < TOOL_FRAMES.length; i++) {
|
|
37
|
+
const line = formatToolLine({ ...base, durationMs: i * 100 });
|
|
38
|
+
assert.strictEqual(firstGlyph(line), TOOL_FRAMES[i], `frame ${i} glyph`);
|
|
39
|
+
}
|
|
40
|
+
// Wraps around (modulo frames.length).
|
|
41
|
+
const wrapped = formatToolLine({ ...base, durationMs: TOOL_FRAMES.length * 100 });
|
|
42
|
+
assert.strictEqual(firstGlyph(wrapped), TOOL_FRAMES[0], 'frame index wraps');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the glyph actually changes between consecutive ~100ms samples', () => {
|
|
46
|
+
const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
|
|
47
|
+
const g0 = firstGlyph(formatToolLine({ ...base, durationMs: 0 }));
|
|
48
|
+
const g1 = firstGlyph(formatToolLine({ ...base, durationMs: 100 }));
|
|
49
|
+
const g2 = firstGlyph(formatToolLine({ ...base, durationMs: 200 }));
|
|
50
|
+
assert.notStrictEqual(g0, g1, 'glyph advances 0 → 100ms');
|
|
51
|
+
assert.notStrictEqual(g1, g2, 'glyph advances 100 → 200ms');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// The elapsed meter advances with the duration (the frozen-timer fix).
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
test('the running elapsed meter advances as durationMs grows', () => {
|
|
59
|
+
const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
|
|
60
|
+
const at1 = plain(formatToolLine({ ...base, durationMs: 1200 }));
|
|
61
|
+
const at3 = plain(formatToolLine({ ...base, durationMs: 3400 }));
|
|
62
|
+
assert.ok(at1.includes('1.2s'), `elapsed shows 1.2s: ${at1}`);
|
|
63
|
+
assert.ok(at3.includes('3.4s'), `elapsed shows 3.4s: ${at3}`);
|
|
64
|
+
// Pending lines trail the duration with an ellipsis.
|
|
65
|
+
assert.ok(at1.includes('1.2s…'), 'pending duration trails with …');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Blocking tools (ask_user, rendered noDuration) keep the static dot — a
|
|
70
|
+
// ticking spinner would falsely imply work is happening.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
test('blocking (noDuration) pending tools keep the static dot, not a spinner', () => {
|
|
74
|
+
const line = formatToolLine({
|
|
75
|
+
status: 'pending', tag: 'ask_user', arg: 'Pick one', attrs: { question: 'Pick one' },
|
|
76
|
+
category: 'tool', noDuration: true,
|
|
77
|
+
});
|
|
78
|
+
assert.strictEqual(firstGlyph(line), '●', 'frozen blocking glyph stays the pending dot');
|
|
79
|
+
assert.ok(!TOOL_FRAMES.includes(firstGlyph(line)), 'not a spinner frame');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// The descriptor path (renderOperation, pending phase) animates the same way.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
test('renderOperation pending phase animates the glyph via durationMs', () => {
|
|
87
|
+
const mk = (durationMs) => renderOperation(
|
|
88
|
+
{
|
|
89
|
+
status: 'pending', tag: 'read_file', target: 'a.txt', attrs: { path: 'a.txt' },
|
|
90
|
+
category: 'file', durationMs,
|
|
91
|
+
},
|
|
92
|
+
{ mode: 'ansi', phase: 'pending' },
|
|
93
|
+
);
|
|
94
|
+
const g0 = firstGlyph(mk(0));
|
|
95
|
+
const g1 = firstGlyph(mk(100));
|
|
96
|
+
assert.strictEqual(g0, TOOL_FRAMES[0], 'descriptor pending → spinner frame 0 at 0ms');
|
|
97
|
+
assert.notStrictEqual(g0, g1, 'descriptor pending glyph advances with elapsed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Single physical row: the animated running line carries no newline (Phase 4
|
|
102
|
+
// owns wrap-aware multi-row; this phase must not introduce multi-row content).
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test('the animated running row is a single physical line (no newline)', () => {
|
|
106
|
+
const line = formatToolLine({
|
|
107
|
+
status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' },
|
|
108
|
+
category: 'shell', durationMs: 500,
|
|
109
|
+
});
|
|
110
|
+
assert.ok(!line.includes('\n'), 'no newline in the running row');
|
|
111
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Status bar ↔ single driver coordination (Output Refactor — Phase 3).
|
|
4
|
+
//
|
|
5
|
+
// The status bar wires the clock and spinner as two SUBSCRIBERS of one driver
|
|
6
|
+
// (lib/ui/anim.js). These tests assert the coordination contract through the
|
|
7
|
+
// real FullStatusBar: one coordinated repaint per tick (no flicker), the driver
|
|
8
|
+
// runs while animating even when idle-paused (so a running tool's spinner keeps
|
|
9
|
+
// moving), and it stops when fully idle (preserving the 5404bd0 scroll fix).
|
|
10
|
+
|
|
11
|
+
const { test, mock } = require('node:test');
|
|
12
|
+
const assert = require('node:assert');
|
|
13
|
+
|
|
14
|
+
const { FullStatusBar } = require('../lib/ui/status-bar');
|
|
15
|
+
const { TICKS_PER_SECOND, BASE_INTERVAL_MS } = require('../lib/ui/anim');
|
|
16
|
+
|
|
17
|
+
const layout = { cols: 200 };
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// No flicker: a frame where BOTH the clock and the spinner want a repaint
|
|
21
|
+
// still produces exactly ONE repaint (one writer frame, not two competing).
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
test('a frame firing both clock and spinner yields a single repaint', () => {
|
|
25
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
26
|
+
try {
|
|
27
|
+
let redraws = 0;
|
|
28
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
29
|
+
bar.update('tool', 'running'); // animating → spinner wants every tick
|
|
30
|
+
|
|
31
|
+
// Advance to exactly the 1 Hz boundary (frame === TICKS_PER_SECOND), where
|
|
32
|
+
// the clock subscriber ALSO wants a repaint. The two coalesce into one.
|
|
33
|
+
redraws = 0;
|
|
34
|
+
mock.timers.tick(BASE_INTERVAL_MS * TICKS_PER_SECOND);
|
|
35
|
+
// One repaint per base tick (spinner), and the clock-boundary tick did not
|
|
36
|
+
// double up: exactly TICKS_PER_SECOND repaints over the second, not more.
|
|
37
|
+
assert.strictEqual(redraws, TICKS_PER_SECOND, 'one coordinated repaint per tick, no doubling at the 1s boundary');
|
|
38
|
+
|
|
39
|
+
bar.destroy();
|
|
40
|
+
} finally {
|
|
41
|
+
mock.timers.reset();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// While animating, idle-pause keeps the driver running (spinner must move) but
|
|
47
|
+
// silences the clock contribution — so there's no extra 1 Hz scroll-fighting
|
|
48
|
+
// tick beyond the spinner's own repaints.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
test('paused-but-animating keeps the spinner running', () => {
|
|
52
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
53
|
+
try {
|
|
54
|
+
let redraws = 0;
|
|
55
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
56
|
+
bar.update('tool', 'running'); // animating
|
|
57
|
+
bar.pause(); // input went idle (agent working)
|
|
58
|
+
|
|
59
|
+
redraws = 0;
|
|
60
|
+
mock.timers.tick(BASE_INTERVAL_MS * 5);
|
|
61
|
+
// Driver still runs (spinner) — 5 base ticks → 5 repaints.
|
|
62
|
+
assert.strictEqual(redraws, 5, 'spinner keeps repainting while paused-but-animating');
|
|
63
|
+
|
|
64
|
+
bar.destroy();
|
|
65
|
+
} finally {
|
|
66
|
+
mock.timers.reset();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Returning to idle while paused stops the driver outright (idle = no anim).
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test('idle + paused stops the driver (no periodic repaint → scroll works)', () => {
|
|
75
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
76
|
+
try {
|
|
77
|
+
let redraws = 0;
|
|
78
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
79
|
+
bar.update('tool', 'running');
|
|
80
|
+
bar.pause();
|
|
81
|
+
// Tool finished → back to idle while still paused.
|
|
82
|
+
bar.update('idle'); // update() un-pauses + restarts driver...
|
|
83
|
+
bar.pause(); // ...and the field is still idle → re-pause stops it.
|
|
84
|
+
|
|
85
|
+
redraws = 0;
|
|
86
|
+
mock.timers.tick(BASE_INTERVAL_MS * 50);
|
|
87
|
+
assert.strictEqual(redraws, 0, 'no periodic repaint when idle + paused');
|
|
88
|
+
|
|
89
|
+
bar.destroy();
|
|
90
|
+
} finally {
|
|
91
|
+
mock.timers.reset();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Status-bar clock re-sync after startup — the await-ordering race fix.
|
|
4
|
+
//
|
|
5
|
+
// THE BUG (uncovered by the 5404bd0 idle-scroll fix): two correct-in-isolation
|
|
6
|
+
// mechanisms race during startup.
|
|
7
|
+
//
|
|
8
|
+
// Fact A — statusBar.update('idle') unconditionally restarts the clock
|
|
9
|
+
// (the "not-paused ⇒ clock-running" invariant).
|
|
10
|
+
// Fact B — the input field's idle→pause is a ONE-SHOT setTimeout(0): on the
|
|
11
|
+
// first event-loop yield the field goes idle, emits 'idle', and the wiring
|
|
12
|
+
// (`field.on('idle', () => sb.pause())`) stops the clock. It fires once; no
|
|
13
|
+
// further 'idle' is emitted until an active→idle transition.
|
|
14
|
+
//
|
|
15
|
+
// No-await start: update('idle') runs synchronously, THEN the loop yields and
|
|
16
|
+
// the one-shot _goIdle fires last → pause() → clock stopped. ✓
|
|
17
|
+
// Resume / MCP start: an `await` (dashboardGetChat / mcpManager.connectAll)
|
|
18
|
+
// yields mid-startup, so the one-shot _goIdle fires EARLY (clock stopped),
|
|
19
|
+
// then the await resolves and update('idle') restarts the clock. The field is
|
|
20
|
+
// already idle, so no active→idle transition ever re-fires pause() → clock
|
|
21
|
+
// runs forever → the 1 Hz redraw snaps the viewport down → scroll defeated. ✗
|
|
22
|
+
//
|
|
23
|
+
// THE FIX (chat.js, right after the startup update('idle')):
|
|
24
|
+
// if (inputField.isIdle()) statusBar.pause();
|
|
25
|
+
// It re-syncs the clock to the field's REAL idle state. These tests model the
|
|
26
|
+
// exact startup wiring (create-ui.js:196-197) with the real FullStatusBar and
|
|
27
|
+
// InputField, driving the field's idle state to reproduce both orderings.
|
|
28
|
+
|
|
29
|
+
const { test, mock } = require('node:test');
|
|
30
|
+
const assert = require('node:assert');
|
|
31
|
+
|
|
32
|
+
const { FullStatusBar } = require('../lib/ui/status-bar');
|
|
33
|
+
const { InputField } = require('../lib/ui/input-field');
|
|
34
|
+
|
|
35
|
+
const layout = { cols: 200 };
|
|
36
|
+
|
|
37
|
+
// Build a real status bar + input field wired exactly like create-ui.js, plus
|
|
38
|
+
// the one-line convergence re-sync the fix adds. Returns helpers to drive the
|
|
39
|
+
// orderings and to count clock redraws.
|
|
40
|
+
function harness() {
|
|
41
|
+
let redraws = 0;
|
|
42
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
43
|
+
const field = new InputField(layout, { /* chatHistory stub */ }, () => {});
|
|
44
|
+
// create-ui.js:196-197 — the live wiring under test.
|
|
45
|
+
field.on('idle', () => bar.pause());
|
|
46
|
+
field.on('active', () => bar.resume());
|
|
47
|
+
|
|
48
|
+
// The startup sequence at chat.js: the final update('idle') followed by the
|
|
49
|
+
// re-sync convergence step (the fix).
|
|
50
|
+
const startupResync = () => {
|
|
51
|
+
bar.update('idle');
|
|
52
|
+
if (field.isIdle()) bar.pause();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
bar,
|
|
57
|
+
field,
|
|
58
|
+
startupResync,
|
|
59
|
+
redrawsSince(fn) { redraws = 0; fn(); return redraws; },
|
|
60
|
+
cleanup() {
|
|
61
|
+
// _goActive arms a real setTimeout(0); clear any dangling one.
|
|
62
|
+
if (field._idleTimer) { clearTimeout(field._idleTimer); field._idleTimer = null; }
|
|
63
|
+
bar.destroy();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Resume / await-ordering: the one-shot _goIdle fires BEFORE the re-sync → the
|
|
70
|
+
// re-sync must leave the clock STOPPED (the regression).
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
test('await-ordering (resume): re-sync stops the clock the update restarted', () => {
|
|
74
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
75
|
+
const h = harness();
|
|
76
|
+
try {
|
|
77
|
+
// An await yielded mid-startup → the one-shot _goIdle already fired.
|
|
78
|
+
h.field._goIdle();
|
|
79
|
+
assert.ok(h.field.isIdle(), 'field is idle after the early _goIdle');
|
|
80
|
+
|
|
81
|
+
// WITHOUT the re-sync, update('idle') alone leaves the clock running —
|
|
82
|
+
// this is exactly the bug. (Measure only the periodic tick, not update()'s
|
|
83
|
+
// own synchronous repaint.)
|
|
84
|
+
h.bar.update('idle');
|
|
85
|
+
const bugTicks = h.redrawsSince(() => mock.timers.tick(3000));
|
|
86
|
+
assert.ok(bugTicks >= 3, 'update(idle) alone restarts the clock (the bug)');
|
|
87
|
+
|
|
88
|
+
// WITH the re-sync: field is already idle → pause() → clock stopped.
|
|
89
|
+
h.startupResync();
|
|
90
|
+
const fixedTicks = h.redrawsSince(() => mock.timers.tick(5000));
|
|
91
|
+
assert.strictEqual(fixedTicks, 0, 're-sync stops the clock when field is idle');
|
|
92
|
+
} finally {
|
|
93
|
+
h.cleanup();
|
|
94
|
+
mock.timers.reset();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Normal no-await start: field is NOT yet idle at the re-sync point → no
|
|
100
|
+
// premature pause; the existing flow (timer fires later → pause) still ends
|
|
101
|
+
// clock-stopped. (Paired positive — the fix doesn't break the working path.)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
test('no-await start: re-sync is a no-op, existing idle→pause still stops the clock', () => {
|
|
105
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
106
|
+
const h = harness();
|
|
107
|
+
try {
|
|
108
|
+
// The one-shot _goIdle has NOT fired yet at the re-sync point.
|
|
109
|
+
assert.ok(!h.field.isIdle(), 'field not yet idle at re-sync');
|
|
110
|
+
|
|
111
|
+
// Re-sync runs: isIdle() false → no premature pause → clock keeps running.
|
|
112
|
+
h.startupResync();
|
|
113
|
+
const afterResync = h.redrawsSince(() => mock.timers.tick(3000));
|
|
114
|
+
assert.ok(afterResync >= 3, 'no premature pause on the no-await path');
|
|
115
|
+
|
|
116
|
+
// The one-shot _goIdle fires AFTER startup (as it always did) → pause().
|
|
117
|
+
const afterIdle = h.redrawsSince(() => { h.field._goIdle(); mock.timers.tick(5000); });
|
|
118
|
+
assert.strictEqual(afterIdle, 0, 'the later idle still stops the clock');
|
|
119
|
+
} finally {
|
|
120
|
+
h.cleanup();
|
|
121
|
+
mock.timers.reset();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// MCP-start equivalence: the fix is keyed on the field's idle state, not on
|
|
127
|
+
// WHICH await yielded, so a startup await from MCP connectAll converges the
|
|
128
|
+
// same way as --resume. Same ordering → same stopped end state.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
test('await-ordering (MCP start): re-sync also stops the clock', () => {
|
|
132
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
133
|
+
const h = harness();
|
|
134
|
+
try {
|
|
135
|
+
// mcpManager.connectAll() yielded the loop → one-shot _goIdle fired early.
|
|
136
|
+
h.field._goIdle();
|
|
137
|
+
h.startupResync();
|
|
138
|
+
const ticks = h.redrawsSince(() => mock.timers.tick(5000));
|
|
139
|
+
assert.strictEqual(ticks, 0, 'MCP-start await-ordering ends with the clock stopped');
|
|
140
|
+
} finally {
|
|
141
|
+
h.cleanup();
|
|
142
|
+
mock.timers.reset();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Type-after-resume: an active transition restarts the clock (resume()) and a
|
|
148
|
+
// subsequent idle re-pauses — the normal cycle is intact post-fix.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
test('type-after-resume: active restarts the clock, idle re-pauses', () => {
|
|
152
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
153
|
+
const h = harness();
|
|
154
|
+
try {
|
|
155
|
+
// Reach the post-resume stopped state.
|
|
156
|
+
h.field._goIdle();
|
|
157
|
+
h.startupResync();
|
|
158
|
+
assert.strictEqual(h.redrawsSince(() => mock.timers.tick(3000)), 0, 'stopped after resume');
|
|
159
|
+
|
|
160
|
+
// User types → active → resume() → clock runs again.
|
|
161
|
+
const afterActive = h.redrawsSince(() => { h.field._goActive(); mock.timers.tick(3000); });
|
|
162
|
+
assert.ok(afterActive >= 3, 'typing (active) restarts the clock');
|
|
163
|
+
|
|
164
|
+
// User stops → idle → pause() → clock stops again.
|
|
165
|
+
const afterIdle = h.redrawsSince(() => { h.field._goIdle(); mock.timers.tick(5000); });
|
|
166
|
+
assert.strictEqual(afterIdle, 0, 'going idle re-pauses the clock');
|
|
167
|
+
} finally {
|
|
168
|
+
h.cleanup();
|
|
169
|
+
mock.timers.reset();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// isIdle() reflects the real _goIdle/_goActive transitions.
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
test('isIdle() reads the true idle state across transitions', () => {
|
|
178
|
+
const h = harness();
|
|
179
|
+
try {
|
|
180
|
+
assert.strictEqual(h.field.isIdle(), false, 'starts not-idle');
|
|
181
|
+
h.field._goIdle();
|
|
182
|
+
assert.strictEqual(h.field.isIdle(), true, 'true after _goIdle');
|
|
183
|
+
h.field._goActive();
|
|
184
|
+
assert.strictEqual(h.field.isIdle(), false, 'false after _goActive');
|
|
185
|
+
} finally {
|
|
186
|
+
h.cleanup();
|
|
187
|
+
}
|
|
188
|
+
});
|