@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,409 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Native-path dispatch tests (Pre-Task 4.0c). Closes the coverage-shape blind
|
|
4
|
+
// spot the re-audit found: end-to-end dispatch through the REAL runAgentLoop was
|
|
5
|
+
// proven for read-only tools on BOTH rails (Task 3.3b), but EFFECTFUL tools were
|
|
6
|
+
// only ever exercised through the XML path. The native function-calling path has
|
|
7
|
+
// distinct glue — mapInvokeToCall → descriptor gate → role:'tool' result rooting
|
|
8
|
+
// (lib/agent.js ~1378) — and Phase 4's per-pattern permissions + checkpoints both
|
|
9
|
+
// hook the MUTATING dispatch path. Layering that onto unverified native glue would
|
|
10
|
+
// repeat the 3.3b mistake, so these tests lock the native path end-to-end for:
|
|
11
|
+
// * file-mutating tools (write, edit, delete, move),
|
|
12
|
+
// * shell/exec,
|
|
13
|
+
// * plan-mode withhold + approve,
|
|
14
|
+
// each asserting the mutation actually happens, the permission gate fires (with
|
|
15
|
+
// the right descriptor), and the result is rooted as a role:'tool' message on the
|
|
16
|
+
// originating tool_call_id. Where useful each native case is paired with its XML
|
|
17
|
+
// equivalent IN THE SAME TEST so the two rails are proven equivalent at the loop
|
|
18
|
+
// level for effectful tools (extending the 3.3b read-only equivalence proof).
|
|
19
|
+
//
|
|
20
|
+
// Driven via mock.replyWithToolCall(name, args) — a native tool_calls response
|
|
21
|
+
// with EMPTY text content — against a temp $cwd so isPathSafe (CWD-confined)
|
|
22
|
+
// permits the writes. skipPermissions auto-approves so the loop runs unattended;
|
|
23
|
+
// pm.askPermission is wrapped to RECORD each gate consultation so we can assert
|
|
24
|
+
// the descriptor fired for mutating tools and did NOT for read-only ones.
|
|
25
|
+
|
|
26
|
+
const { test, before, after } = require('node:test');
|
|
27
|
+
const assert = require('node:assert');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
const ui = require('../lib/ui');
|
|
33
|
+
const { createApiClient } = require('../lib/api');
|
|
34
|
+
const { createToolExecutor, extractToolCalls } = require('../lib/tools');
|
|
35
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
36
|
+
const { createAgentRunner } = require('../lib/agent');
|
|
37
|
+
const { startMockLLM } = require('./harness/mock-llm');
|
|
38
|
+
|
|
39
|
+
let prevKey;
|
|
40
|
+
let CWD;
|
|
41
|
+
let PREV_CWD;
|
|
42
|
+
before(() => {
|
|
43
|
+
prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key';
|
|
44
|
+
PREV_CWD = process.cwd();
|
|
45
|
+
CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-native-')));
|
|
46
|
+
process.chdir(CWD);
|
|
47
|
+
});
|
|
48
|
+
after(() => {
|
|
49
|
+
process.chdir(PREV_CWD);
|
|
50
|
+
try { fs.rmSync(CWD, { recursive: true, force: true }); } catch {}
|
|
51
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY; else process.env.SEMALT_API_KEY = prevKey;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// A real runner whose chatStream points at `base`. skipPermissions auto-approves
|
|
55
|
+
// so the loop runs unattended; `asks` records every permission-gate consultation
|
|
56
|
+
// (actionType + tag) so a test can prove the descriptor fired (mutating) or did
|
|
57
|
+
// not (read-only). Returns `getSaved` for the rare persistence assertion.
|
|
58
|
+
function buildRunner(base) {
|
|
59
|
+
const config = {
|
|
60
|
+
api_base: base, api_key: 'test-key', default_model: 'test-model',
|
|
61
|
+
temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
|
|
62
|
+
// Dispatch test, not a sandbox test — run real `echo` unsandboxed regardless
|
|
63
|
+
// of the runner's bwrap/sandbox-exec availability (Task 4.4).
|
|
64
|
+
sandbox: { mode: 'off' },
|
|
65
|
+
};
|
|
66
|
+
let saved = null;
|
|
67
|
+
const getConfig = () => config;
|
|
68
|
+
const saveConfig = (c) => { saved = { ...c }; Object.assign(config, c); };
|
|
69
|
+
|
|
70
|
+
const api = createApiClient({ getConfig, saveConfig, ui });
|
|
71
|
+
const pm = createPermissionManager(ui, { skipPermissions: true });
|
|
72
|
+
pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
|
|
73
|
+
|
|
74
|
+
const asks = [];
|
|
75
|
+
const realAsk = pm.askPermission;
|
|
76
|
+
pm.askPermission = async (actionType, description, tag) => {
|
|
77
|
+
asks.push({ actionType, tag });
|
|
78
|
+
return realAsk(actionType, description, tag);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
|
|
82
|
+
const runner = createAgentRunner({
|
|
83
|
+
chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
|
|
84
|
+
describePermission, permissionManager: pm, ui, getConfig,
|
|
85
|
+
});
|
|
86
|
+
return { runner, asks, getSaved: () => saved };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collector(extra = {}) {
|
|
90
|
+
const ev = { tokens: [], tools: [], errors: [], retries: [], assistants: [], withheld: [] };
|
|
91
|
+
const cb = {
|
|
92
|
+
onToken: (t) => ev.tokens.push(t),
|
|
93
|
+
onToolStart: () => {},
|
|
94
|
+
onToolEnd: (tag, result) => ev.tools.push({ tag, result }),
|
|
95
|
+
onError: (e) => ev.errors.push(e),
|
|
96
|
+
onRetry: (next, max) => ev.retries.push({ next, max }),
|
|
97
|
+
onAssistantMessage: (m) => ev.assistants.push(m),
|
|
98
|
+
onPlanWithhold: (tag, arg, desc) => ev.withheld.push({ tag, arg, desc }),
|
|
99
|
+
...extra,
|
|
100
|
+
};
|
|
101
|
+
return { ev, cb };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The assistant turn that carried structured tool_calls (native shape), plus its
|
|
105
|
+
// rooted role:'tool' result. Shared assertion: a native tool turn records an
|
|
106
|
+
// assistant message with EMPTY text + tool_calls, and the result comes back on a
|
|
107
|
+
// role:'tool' message keyed to the originating tool_call id (lib/agent.js ~1378).
|
|
108
|
+
function assertNativeRooting(messages, fnName) {
|
|
109
|
+
const assistantWithCall = messages.find((m) => m.role === 'assistant' && Array.isArray(m.tool_calls));
|
|
110
|
+
assert.ok(assistantWithCall, 'assistant message recorded the native tool_calls');
|
|
111
|
+
assert.strictEqual(assistantWithCall.content, '', 'native tool-call turn has empty text content');
|
|
112
|
+
assert.strictEqual(assistantWithCall.tool_calls[0].function.name, fnName, `tool_calls names ${fnName}`);
|
|
113
|
+
const toolMsg = messages.find((m) => m.role === 'tool');
|
|
114
|
+
assert.ok(toolMsg, 'native path appends a role:"tool" result message');
|
|
115
|
+
assert.strictEqual(toolMsg.tool_call_id, assistantWithCall.tool_calls[0].id, 'result rooted to its tool_call id');
|
|
116
|
+
assert.ok(!messages.some((m) => m.role === 'user' && /Tool execution results/.test(m.content)),
|
|
117
|
+
'native path does NOT use the XML "Tool execution results" user message');
|
|
118
|
+
return toolMsg;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// 1. Native file-mutating: write — mutation + gate + role:'tool' rooting,
|
|
123
|
+
// paired with the XML equivalent for loop-level equivalence.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
test('native write_file: mutates, gate fires (file/write_file), result rooted as role:"tool"', async () => {
|
|
127
|
+
const mock = await startMockLLM();
|
|
128
|
+
mock.replyWithToolCall('write_file', { path: 'native-write.txt', content: 'NATIVE_WRITE_CONTENT' });
|
|
129
|
+
mock.replyWith('Wrote it.');
|
|
130
|
+
try {
|
|
131
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
132
|
+
const { ev, cb } = collector();
|
|
133
|
+
const messages = [{ role: 'user', content: 'write the file' }];
|
|
134
|
+
const { metrics } = await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
135
|
+
|
|
136
|
+
// The mutation actually happened.
|
|
137
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'native-write.txt'), 'utf8'), 'NATIVE_WRITE_CONTENT');
|
|
138
|
+
// The permission gate fired with the mutating descriptor (NOT auto-skipped
|
|
139
|
+
// as a read-only tool).
|
|
140
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'write_file' }], 'write gate consulted once');
|
|
141
|
+
// The tool dispatched and the result is rooted on the tool_call id.
|
|
142
|
+
assert.strictEqual(ev.tools.length, 1);
|
|
143
|
+
assert.strictEqual(ev.tools[0].tag, 'write');
|
|
144
|
+
const toolMsg = assertNativeRooting(messages, 'write_file');
|
|
145
|
+
assert.match(toolMsg.content, /Wrote \d+ bytes to native-write\.txt/);
|
|
146
|
+
|
|
147
|
+
assert.strictEqual(metrics.turns.length, 2, 'tool turn + final turn');
|
|
148
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'Wrote it.'));
|
|
149
|
+
assert.strictEqual(mock.pending(), 0);
|
|
150
|
+
} finally {
|
|
151
|
+
await mock.close();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('XML write_file equivalent: same tool, same mutation, XML "Tool execution results" shape', async () => {
|
|
156
|
+
const mock = await startMockLLM();
|
|
157
|
+
mock.replyWith('<write_file path="xml-write.txt">XML_WRITE_CONTENT</write_file>');
|
|
158
|
+
mock.replyWith('Wrote it.');
|
|
159
|
+
try {
|
|
160
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
161
|
+
const { ev, cb } = collector();
|
|
162
|
+
const messages = [{ role: 'user', content: 'write the file' }];
|
|
163
|
+
await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
164
|
+
|
|
165
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'xml-write.txt'), 'utf8'), 'XML_WRITE_CONTENT');
|
|
166
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'write_file' }], 'same gate fires on the XML rail');
|
|
167
|
+
assert.strictEqual(ev.tools[0].tag, 'write');
|
|
168
|
+
// XML results come back as a role:'user' message, never role:'tool'.
|
|
169
|
+
const toolResult = messages.find((m) => m.role === 'user' && /Tool execution results/.test(m.content));
|
|
170
|
+
assert.ok(toolResult && /Wrote \d+ bytes to xml-write\.txt/.test(toolResult.content));
|
|
171
|
+
assert.ok(!messages.some((m) => m.role === 'tool'), 'XML path does not use role:"tool" messages');
|
|
172
|
+
} finally {
|
|
173
|
+
await mock.close();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 2. Native file-mutating: edit_file (line replacement) — multi-arg native call.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
test('native edit_file: replaces the target line, gate fires, result rooted', async () => {
|
|
182
|
+
fs.writeFileSync(path.join(CWD, 'native-edit.txt'), 'line1\nline2\nline3\n');
|
|
183
|
+
const mock = await startMockLLM();
|
|
184
|
+
// fromParams: { path, line, content } → ['edit_file', path, parseInt(line), content]
|
|
185
|
+
mock.replyWithToolCall('edit_file', { path: 'native-edit.txt', line: 2, content: 'EDITED_LINE_2' });
|
|
186
|
+
mock.replyWith('Edited.');
|
|
187
|
+
try {
|
|
188
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
189
|
+
const { ev, cb } = collector();
|
|
190
|
+
const messages = [{ role: 'user', content: 'edit line 2' }];
|
|
191
|
+
await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
192
|
+
|
|
193
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'native-edit.txt'), 'utf8'), 'line1\nEDITED_LINE_2\nline3\n');
|
|
194
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'edit_file' }]);
|
|
195
|
+
assert.strictEqual(ev.tools[0].tag, 'edit_file');
|
|
196
|
+
assertNativeRooting(messages, 'edit_file');
|
|
197
|
+
} finally {
|
|
198
|
+
await mock.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// 3. Native file-mutating: delete_file.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
test('native delete_file: removes the file, gate fires, result rooted', async () => {
|
|
207
|
+
const target = path.join(CWD, 'native-delete.txt');
|
|
208
|
+
fs.writeFileSync(target, 'doomed');
|
|
209
|
+
const mock = await startMockLLM();
|
|
210
|
+
mock.replyWithToolCall('delete_file', { path: 'native-delete.txt' });
|
|
211
|
+
mock.replyWith('Deleted.');
|
|
212
|
+
try {
|
|
213
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
214
|
+
const { ev, cb } = collector();
|
|
215
|
+
const messages = [{ role: 'user', content: 'delete it' }];
|
|
216
|
+
await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
217
|
+
|
|
218
|
+
assert.ok(!fs.existsSync(target), 'the file was deleted');
|
|
219
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'delete_file' }]);
|
|
220
|
+
assert.strictEqual(ev.tools[0].tag, 'delete_file');
|
|
221
|
+
const toolMsg = assertNativeRooting(messages, 'delete_file');
|
|
222
|
+
assert.match(toolMsg.content, /Deleted native-delete\.txt/);
|
|
223
|
+
} finally {
|
|
224
|
+
await mock.close();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// 4. Native file-mutating: move_file (multi-arg src/dst).
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
test('native move_file: renames src→dst, gate fires, result rooted', async () => {
|
|
233
|
+
fs.writeFileSync(path.join(CWD, 'native-src.txt'), 'movable');
|
|
234
|
+
const mock = await startMockLLM();
|
|
235
|
+
mock.replyWithToolCall('move_file', { src: 'native-src.txt', dst: 'native-dst.txt' });
|
|
236
|
+
mock.replyWith('Moved.');
|
|
237
|
+
try {
|
|
238
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
239
|
+
const { ev, cb } = collector();
|
|
240
|
+
const messages = [{ role: 'user', content: 'move it' }];
|
|
241
|
+
await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
242
|
+
|
|
243
|
+
assert.ok(!fs.existsSync(path.join(CWD, 'native-src.txt')), 'source gone');
|
|
244
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'native-dst.txt'), 'utf8'), 'movable', 'dst has the content');
|
|
245
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'move_file' }]);
|
|
246
|
+
assert.strictEqual(ev.tools[0].tag, 'move_file');
|
|
247
|
+
assertNativeRooting(messages, 'move_file');
|
|
248
|
+
} finally {
|
|
249
|
+
await mock.close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// 5. Native shell/exec — paired with the XML <exec> equivalent.
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
test('native shell: dispatches, gate fires (shell/exec), result rooted as role:"tool"', async () => {
|
|
258
|
+
const mock = await startMockLLM();
|
|
259
|
+
mock.replyWithToolCall('shell', { command: 'echo NATIVE_SHELL_OUT' });
|
|
260
|
+
mock.replyWith('Ran it.');
|
|
261
|
+
try {
|
|
262
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
263
|
+
const { ev, cb } = collector();
|
|
264
|
+
const messages = [{ role: 'user', content: 'run echo' }];
|
|
265
|
+
const { metrics } = await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
266
|
+
|
|
267
|
+
assert.deepStrictEqual(asks, [{ actionType: 'shell', tag: 'exec' }], 'shell gate consulted with exec tag');
|
|
268
|
+
assert.strictEqual(ev.tools.length, 1);
|
|
269
|
+
assert.strictEqual(ev.tools[0].tag, 'shell');
|
|
270
|
+
|
|
271
|
+
const toolMsg = assertNativeRooting(messages, 'shell');
|
|
272
|
+
assert.match(toolMsg.content, /NATIVE_SHELL_OUT/, 'command stdout flowed back');
|
|
273
|
+
assert.match(toolMsg.content, /Exit code: 0/);
|
|
274
|
+
|
|
275
|
+
assert.strictEqual(metrics.turns.length, 2);
|
|
276
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'Ran it.'));
|
|
277
|
+
} finally {
|
|
278
|
+
await mock.close();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('XML shell equivalent: same dispatch, XML "Tool execution results" shape', async () => {
|
|
283
|
+
const mock = await startMockLLM();
|
|
284
|
+
mock.replyWith('<exec>echo XML_SHELL_OUT</exec>');
|
|
285
|
+
mock.replyWith('Ran it.');
|
|
286
|
+
try {
|
|
287
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
288
|
+
const { ev, cb } = collector();
|
|
289
|
+
const messages = [{ role: 'user', content: 'run echo' }];
|
|
290
|
+
await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: cb });
|
|
291
|
+
|
|
292
|
+
assert.deepStrictEqual(asks, [{ actionType: 'shell', tag: 'exec' }], 'same gate fires on the XML rail');
|
|
293
|
+
assert.strictEqual(ev.tools[0].tag, 'shell');
|
|
294
|
+
const toolResult = messages.find((m) => m.role === 'user' && /Tool execution results/.test(m.content));
|
|
295
|
+
assert.ok(toolResult && /XML_SHELL_OUT/.test(toolResult.content) && /Exit code: 0/.test(toolResult.content));
|
|
296
|
+
assert.ok(!messages.some((m) => m.role === 'tool'), 'XML path does not use role:"tool" messages');
|
|
297
|
+
} finally {
|
|
298
|
+
await mock.close();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 6. Native plan-mode: a mutating tool arriving via the native path is WITHHELD,
|
|
304
|
+
// and approval (plan mode off) lets it proceed — mirrors the XML plan test,
|
|
305
|
+
// additionally proving the withheld result is rooted as role:'tool' (native).
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
test('native plan mode: withholds the native mutating tool (no mutation), result rooted as role:"tool"', async () => {
|
|
309
|
+
const target = path.join(CWD, 'native-planned.txt');
|
|
310
|
+
const mock = await startMockLLM();
|
|
311
|
+
mock.replyWithToolCall('write_file', { path: 'native-planned.txt', content: 'SHOULD_NOT_WRITE' });
|
|
312
|
+
mock.replyWith('Here is my plan.');
|
|
313
|
+
try {
|
|
314
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
315
|
+
const { ev, cb } = collector();
|
|
316
|
+
const messages = [{ role: 'user', content: 'change the file' }];
|
|
317
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb, planMode: true });
|
|
318
|
+
|
|
319
|
+
assert.ok(!fs.existsSync(target), 'the file was NOT written in plan mode');
|
|
320
|
+
assert.strictEqual(res.withheldActions.length, 1, 'one action withheld');
|
|
321
|
+
assert.strictEqual(res.withheldActions[0].tag, 'write');
|
|
322
|
+
assert.deepStrictEqual(ev.withheld.map((w) => w.tag), ['write'], 'onPlanWithhold fired for the native call');
|
|
323
|
+
// Plan-mode withholding happens BEFORE the permission gate — never consulted.
|
|
324
|
+
assert.deepStrictEqual(asks, [], 'no permission prompt for a withheld tool');
|
|
325
|
+
|
|
326
|
+
// The withheld notice is still rooted on the native tool_call id (the loop
|
|
327
|
+
// pushes role:'tool' for native calls — lib/agent.js ~1366), keeping the
|
|
328
|
+
// assistant tool_calls ↔ tool-result map consistent for the next turn.
|
|
329
|
+
const toolMsg = assertNativeRooting(messages, 'write_file');
|
|
330
|
+
assert.match(toolMsg.content, /\[plan mode\] Withheld pending approval/);
|
|
331
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'Here is my plan.'), 'plan recorded');
|
|
332
|
+
} finally {
|
|
333
|
+
await mock.close();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('native plan mode OFF (approval): the same native mutating tool executes', async () => {
|
|
338
|
+
const target = path.join(CWD, 'native-approved.txt');
|
|
339
|
+
const mock = await startMockLLM();
|
|
340
|
+
mock.replyWithToolCall('write_file', { path: 'native-approved.txt', content: 'APPROVED' });
|
|
341
|
+
mock.replyWith('Done.');
|
|
342
|
+
try {
|
|
343
|
+
const { runner, asks } = buildRunner(mock.base);
|
|
344
|
+
const { ev, cb } = collector();
|
|
345
|
+
const messages = [{ role: 'user', content: 'write it' }];
|
|
346
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb, planMode: false });
|
|
347
|
+
|
|
348
|
+
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'APPROVED', 'the file was written after approval');
|
|
349
|
+
assert.strictEqual(res.withheldActions.length, 0, 'nothing withheld with plan mode off');
|
|
350
|
+
assert.deepStrictEqual(asks, [{ actionType: 'file', tag: 'write_file' }], 'gate fired on the executing path');
|
|
351
|
+
assert.strictEqual(ev.tools[0].tag, 'write');
|
|
352
|
+
assertNativeRooting(messages, 'write_file');
|
|
353
|
+
} finally {
|
|
354
|
+
await mock.close();
|
|
355
|
+
}
|
|
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
|
+
});
|