@semalt-ai/code 1.8.5 → 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 +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- 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 +195 -29
- package/lib/ui/input-field.js +21 -11
- 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 +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- 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/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- 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 +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- 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/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
|
@@ -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,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Status bar pause/resume — the idle-scroll fix.
|
|
4
|
+
//
|
|
5
|
+
// THE BUG: when the user is idle, the status bar's once-per-second clock tick
|
|
6
|
+
// kept redrawing the live region, snapping the terminal viewport back to the
|
|
7
|
+
// bottom and defeating scroll-up. `pause()` was *meant* to stop that, but it
|
|
8
|
+
// only set a `_paused` flag that the redraw path (`_notify`) ignored — both
|
|
9
|
+
// branches called `_onChange()` identically, so the guard was a no-op and the
|
|
10
|
+
// clock kept firing.
|
|
11
|
+
//
|
|
12
|
+
// THE FIX: pause()/resume() now start/stop the periodic clock `setInterval`
|
|
13
|
+
// itself. pause() clears the timer (no more idle redraws → scroll sticks);
|
|
14
|
+
// resume() recreates it and does a one-shot repaint (viewport returns to the
|
|
15
|
+
// prompt). Event-driven redraws (update/updateMetrics/setCost/spinner) are
|
|
16
|
+
// untouched — only the periodic tick is paused.
|
|
17
|
+
//
|
|
18
|
+
// These tests drive the clock via node:test mock timers so we can advance time
|
|
19
|
+
// deterministically and count the redraws the tick produces.
|
|
20
|
+
|
|
21
|
+
const { test, mock } = require('node:test');
|
|
22
|
+
const assert = require('node:assert');
|
|
23
|
+
|
|
24
|
+
const { FullStatusBar } = require('../lib/ui/status-bar');
|
|
25
|
+
|
|
26
|
+
const layout = { cols: 200 };
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// pause() stops the periodic redraw (the regression this fix is about)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
test('pause() stops the once-per-second clock redraw', () => {
|
|
33
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
34
|
+
try {
|
|
35
|
+
let redraws = 0;
|
|
36
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
37
|
+
|
|
38
|
+
// Baseline: the clock ticks while active.
|
|
39
|
+
mock.timers.tick(3000);
|
|
40
|
+
assert.ok(redraws >= 3, 'clock fires ~once/sec before pause');
|
|
41
|
+
|
|
42
|
+
redraws = 0;
|
|
43
|
+
bar.pause();
|
|
44
|
+
// Advancing several seconds must produce NO redraws from the tick.
|
|
45
|
+
mock.timers.tick(5000);
|
|
46
|
+
assert.strictEqual(redraws, 0, 'no periodic redraw while paused');
|
|
47
|
+
|
|
48
|
+
bar.destroy();
|
|
49
|
+
} finally {
|
|
50
|
+
mock.timers.reset();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Paired positive: resume() restarts the tick + does a one-shot repaint
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
test('resume() restarts the clock and repaints once', () => {
|
|
59
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
60
|
+
try {
|
|
61
|
+
let redraws = 0;
|
|
62
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
63
|
+
bar.pause();
|
|
64
|
+
mock.timers.tick(5000);
|
|
65
|
+
|
|
66
|
+
redraws = 0;
|
|
67
|
+
bar.resume();
|
|
68
|
+
// resume() performs its one-shot repaint immediately so the viewport
|
|
69
|
+
// returns to the input prompt.
|
|
70
|
+
assert.strictEqual(redraws, 1, 'resume() repaints once synchronously');
|
|
71
|
+
|
|
72
|
+
// ...and the periodic tick is running again.
|
|
73
|
+
redraws = 0;
|
|
74
|
+
mock.timers.tick(3000);
|
|
75
|
+
assert.ok(redraws >= 3, 'clock tick resumes after resume()');
|
|
76
|
+
|
|
77
|
+
bar.destroy();
|
|
78
|
+
} finally {
|
|
79
|
+
mock.timers.reset();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Event-driven redraws are NOT suppressed while paused
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('event-driven redraws (update/updateMetrics/setCost) still paint while paused', () => {
|
|
88
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
89
|
+
try {
|
|
90
|
+
let redraws = 0;
|
|
91
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
92
|
+
bar.pause();
|
|
93
|
+
mock.timers.tick(5000);
|
|
94
|
+
assert.strictEqual(redraws, 0, 'tick suppressed while paused');
|
|
95
|
+
|
|
96
|
+
// An explicit update() still paints — only the periodic tick is paused.
|
|
97
|
+
redraws = 0;
|
|
98
|
+
bar.update('thinking', 'Working');
|
|
99
|
+
assert.ok(redraws >= 1, 'update() still repaints during pause');
|
|
100
|
+
|
|
101
|
+
// updateMetrics / setCost also still paint.
|
|
102
|
+
redraws = 0;
|
|
103
|
+
bar.updateMetrics({ contextTokens: 123 });
|
|
104
|
+
assert.ok(redraws >= 1, 'updateMetrics() still repaints');
|
|
105
|
+
|
|
106
|
+
redraws = 0;
|
|
107
|
+
bar.setCost('$0.01');
|
|
108
|
+
assert.ok(redraws >= 1, 'setCost() still repaints');
|
|
109
|
+
|
|
110
|
+
bar.destroy();
|
|
111
|
+
} finally {
|
|
112
|
+
mock.timers.reset();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// No double timers across pause/resume cycles
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
test('repeated pause()/resume() cycles do not stack setInterval timers', () => {
|
|
121
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
122
|
+
try {
|
|
123
|
+
let redraws = 0;
|
|
124
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
125
|
+
|
|
126
|
+
// Several cycles — a leaked/stacked timer would multiply the redraws/sec.
|
|
127
|
+
bar.pause(); bar.resume();
|
|
128
|
+
bar.pause(); bar.resume();
|
|
129
|
+
bar.pause(); bar.resume();
|
|
130
|
+
|
|
131
|
+
// resume()'s one-shot repaints already happened; measure only the tick.
|
|
132
|
+
redraws = 0;
|
|
133
|
+
mock.timers.tick(1000);
|
|
134
|
+
assert.strictEqual(redraws, 1, 'exactly one clock timer fires per second');
|
|
135
|
+
|
|
136
|
+
// Redundant resume() must not add a second timer either.
|
|
137
|
+
bar.resume();
|
|
138
|
+
redraws = 0;
|
|
139
|
+
mock.timers.tick(1000);
|
|
140
|
+
assert.strictEqual(redraws, 1, 'redundant resume() does not stack a timer');
|
|
141
|
+
|
|
142
|
+
bar.destroy();
|
|
143
|
+
} finally {
|
|
144
|
+
mock.timers.reset();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// destroy() stops the clock (no redraw after teardown)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
test('destroy() stops the clock', () => {
|
|
153
|
+
mock.timers.enable({ apis: ['setInterval'] });
|
|
154
|
+
try {
|
|
155
|
+
let redraws = 0;
|
|
156
|
+
const bar = new FullStatusBar(layout, () => { redraws++; });
|
|
157
|
+
bar.destroy();
|
|
158
|
+
redraws = 0;
|
|
159
|
+
mock.timers.tick(5000);
|
|
160
|
+
assert.strictEqual(redraws, 0, 'no redraw after destroy()');
|
|
161
|
+
} finally {
|
|
162
|
+
mock.timers.reset();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Characterization tests for the streaming SSE parser (Task 1.1).
|
|
4
|
+
// There is no standalone `StreamParser` class — the parser is inline in
|
|
5
|
+
// chatStream's `res.on('data')` handler — so it is characterized end-to-end by
|
|
6
|
+
// driving chatStream against a local fake SSE server (test/harness/sse-server).
|
|
7
|
+
|
|
8
|
+
const { test, before, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const { createApiClient } = require('../lib/api');
|
|
12
|
+
const ui = require('../lib/ui');
|
|
13
|
+
const { startSseServer, sse, DONE } = require('./harness/sse-server');
|
|
14
|
+
|
|
15
|
+
// Force the API key to come from env so resolveApiKey never shells out to a
|
|
16
|
+
// keychain during tests.
|
|
17
|
+
let prevKey;
|
|
18
|
+
before(() => {
|
|
19
|
+
prevKey = process.env.SEMALT_API_KEY;
|
|
20
|
+
process.env.SEMALT_API_KEY = 'test-key';
|
|
21
|
+
});
|
|
22
|
+
after(() => {
|
|
23
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
|
|
24
|
+
else process.env.SEMALT_API_KEY = prevKey;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Build an api client whose api_base points at the given server.
|
|
28
|
+
function clientFor(base) {
|
|
29
|
+
const config = {
|
|
30
|
+
api_base: base,
|
|
31
|
+
api_key: 'test-key',
|
|
32
|
+
default_model: 'test-model',
|
|
33
|
+
temperature: 0.5,
|
|
34
|
+
request_timeout_ms: 5000,
|
|
35
|
+
stream: true,
|
|
36
|
+
};
|
|
37
|
+
return createApiClient({ getConfig: () => config, saveConfig: () => {}, ui });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Run a scripted SSE response through chatStream and return the result.
|
|
41
|
+
async function run(chunks, opts = {}) {
|
|
42
|
+
const srv = await startSseServer({ chunks });
|
|
43
|
+
try {
|
|
44
|
+
const api = clientFor(srv.base);
|
|
45
|
+
const tokens = [];
|
|
46
|
+
const res = await api.chatStream(
|
|
47
|
+
[{ role: 'user', content: 'hi' }],
|
|
48
|
+
{ silent: true, onToken: (t) => tokens.push(t), ...opts },
|
|
49
|
+
);
|
|
50
|
+
return { res, tokens };
|
|
51
|
+
} finally {
|
|
52
|
+
await srv.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('clean single-turn content stream assembles full text + provider usage', async () => {
|
|
57
|
+
const { res, tokens } = await run([
|
|
58
|
+
sse({ choices: [{ delta: { content: 'Hello' } }] }),
|
|
59
|
+
sse({ choices: [{ delta: { content: ', world' } }] }),
|
|
60
|
+
sse({ choices: [{ finish_reason: 'stop', delta: {} }] }),
|
|
61
|
+
sse({ usage: { prompt_tokens: 10, completion_tokens: 3 } }),
|
|
62
|
+
DONE,
|
|
63
|
+
]);
|
|
64
|
+
assert.strictEqual(res.content, 'Hello, world');
|
|
65
|
+
assert.strictEqual(res.finish_reason, 'stop');
|
|
66
|
+
assert.deepStrictEqual(res.toolCalls, []);
|
|
67
|
+
assert.strictEqual(res.usage_from_provider, true);
|
|
68
|
+
assert.deepStrictEqual(res.usage, { prompt_tokens: 10, completion_tokens: 3 });
|
|
69
|
+
assert.deepStrictEqual(tokens, ['Hello', ', world']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a data line split across two chunks is buffered and parsed', async () => {
|
|
73
|
+
// The first chunk ends mid-JSON; the parser must hold it until the newline.
|
|
74
|
+
const full = sse({ choices: [{ delta: { content: 'Hi' } }] });
|
|
75
|
+
const cut = Math.floor(full.length / 2);
|
|
76
|
+
const { res } = await run([full.slice(0, cut), full.slice(cut), DONE]);
|
|
77
|
+
assert.strictEqual(res.content, 'Hi');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('reasoning_content is collected separately from content', async () => {
|
|
81
|
+
const { res } = await run([
|
|
82
|
+
sse({ choices: [{ delta: { reasoning_content: 'let me think' } }] }),
|
|
83
|
+
sse({ choices: [{ delta: { content: 'answer' } }] }),
|
|
84
|
+
DONE,
|
|
85
|
+
]);
|
|
86
|
+
assert.strictEqual(res.content, 'answer');
|
|
87
|
+
assert.strictEqual(res.reasoning, 'let me think');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('onReasoning fires once, before content, when reasoning_content is first seen', async () => {
|
|
91
|
+
// Live-narration safety signal (a): the UI eager-open gate needs to know the
|
|
92
|
+
// structured reasoning channel is in use BEFORE the first content token.
|
|
93
|
+
const order = [];
|
|
94
|
+
const srv = await startSseServer({ chunks: [
|
|
95
|
+
sse({ choices: [{ delta: { reasoning_content: 'thinking a' } }] }),
|
|
96
|
+
sse({ choices: [{ delta: { reasoning_content: ' thinking b' } }] }),
|
|
97
|
+
sse({ choices: [{ delta: { content: 'answer' } }] }),
|
|
98
|
+
DONE,
|
|
99
|
+
] });
|
|
100
|
+
try {
|
|
101
|
+
const api = clientFor(srv.base);
|
|
102
|
+
await api.chatStream([{ role: 'user', content: 'hi' }], {
|
|
103
|
+
silent: true,
|
|
104
|
+
onReasoning: () => order.push('reasoning'),
|
|
105
|
+
onToken: (t) => order.push(`token:${t}`),
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
await srv.close();
|
|
109
|
+
}
|
|
110
|
+
// Exactly one onReasoning, and it precedes the first content token.
|
|
111
|
+
assert.deepStrictEqual(order, ['reasoning', 'token:answer']);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('native tool_calls deltas accumulate by index into structured calls', async () => {
|
|
115
|
+
const { res } = await run([
|
|
116
|
+
sse({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '' } }] } }] }),
|
|
117
|
+
sse({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"path":' } }] } }] }),
|
|
118
|
+
sse({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"a.txt"}' } }] } }] }),
|
|
119
|
+
sse({ choices: [{ finish_reason: 'tool_calls', delta: {} }] }),
|
|
120
|
+
DONE,
|
|
121
|
+
], { nativeTools: true });
|
|
122
|
+
|
|
123
|
+
assert.strictEqual(res.tool_calls_count, 1);
|
|
124
|
+
assert.deepStrictEqual(res.toolCalls, [
|
|
125
|
+
{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '{"path":"a.txt"}' } },
|
|
126
|
+
]);
|
|
127
|
+
assert.strictEqual(res.content, '', 'native mode does not serialize XML into content');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('legacy (nativeTools:false) serializes tool_calls into MiniMax XML in content', async () => {
|
|
131
|
+
const { res } = await run([
|
|
132
|
+
sse({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', type: 'function', function: { name: 'read_file', arguments: '{"path":"a.txt"}' } }] } }] }),
|
|
133
|
+
sse({ choices: [{ finish_reason: 'tool_calls', delta: {} }] }),
|
|
134
|
+
DONE,
|
|
135
|
+
], { nativeTools: false });
|
|
136
|
+
|
|
137
|
+
assert.deepStrictEqual(res.toolCalls, [], 'legacy mode reports no structured calls');
|
|
138
|
+
assert.match(res.content, /<minimax:tool_call>/);
|
|
139
|
+
// And the round-tripped XML is parseable by extractToolCalls.
|
|
140
|
+
const { extractToolCalls } = require('../lib/tools');
|
|
141
|
+
assert.deepStrictEqual(extractToolCalls(res.content), [['read', 'a.txt', null, null, false]]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('usage is estimated locally when the provider omits it', async () => {
|
|
145
|
+
const { res } = await run([
|
|
146
|
+
sse({ choices: [{ delta: { content: 'no usage line here' } }] }),
|
|
147
|
+
DONE,
|
|
148
|
+
]);
|
|
149
|
+
assert.strictEqual(res.usage_from_provider, false);
|
|
150
|
+
assert.strictEqual(typeof res.usage.prompt_tokens, 'number');
|
|
151
|
+
assert.strictEqual(typeof res.usage.completion_tokens, 'number');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a malformed JSON data line is skipped, not fatal', async () => {
|
|
155
|
+
const { res } = await run([
|
|
156
|
+
'data: {this is not json}\n',
|
|
157
|
+
sse({ choices: [{ delta: { content: 'ok' } }] }),
|
|
158
|
+
DONE,
|
|
159
|
+
]);
|
|
160
|
+
assert.strictEqual(res.content, 'ok');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('[DONE] terminates the stream and resolves', async () => {
|
|
164
|
+
const { res } = await run([
|
|
165
|
+
sse({ choices: [{ delta: { content: 'done-test' } }] }),
|
|
166
|
+
DONE,
|
|
167
|
+
// anything after [DONE] is ignored — the parser has already finalized.
|
|
168
|
+
sse({ choices: [{ delta: { content: 'IGNORED' } }] }),
|
|
169
|
+
]);
|
|
170
|
+
assert.strictEqual(res.content, 'done-test');
|
|
171
|
+
});
|