@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,522 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// File-activity grouping — a SECOND INSTANCE of the web-activity collapser for
|
|
4
|
+
// consecutive pure file reads/lists (read_file / list_dir). read_file and
|
|
5
|
+
// list_dir share ONE group key, so a mixed read/list exploration phase collapses
|
|
6
|
+
// into a single summary (neutral "file ×N" verb) instead of fragmenting; a
|
|
7
|
+
// homogeneous run keeps its specific "read ×N" / "list ×N" verb. Covers the live
|
|
8
|
+
// flush sites (driven through the REAL createTurnHandler callbacks, exactly as
|
|
9
|
+
// web-activity-ordering.test.js does for web ops), the flush-time THRESHOLD
|
|
10
|
+
// (1–2 individual lines, 3+ collapsed summary), the merged read/list group, the
|
|
11
|
+
// error-breaks-the-group ordering, terminal-flag gating across iterations, the
|
|
12
|
+
// double-flush guard, and replay re-grouping at the replay terminal width.
|
|
13
|
+
|
|
14
|
+
const { test } = require('node:test');
|
|
15
|
+
const assert = require('node:assert');
|
|
16
|
+
|
|
17
|
+
// Stable colour env for byte comparisons (node:test isolates each file's process).
|
|
18
|
+
process.stdout.isTTY = true;
|
|
19
|
+
delete process.env.NO_COLOR;
|
|
20
|
+
|
|
21
|
+
const { stripAnsi } = require('../lib/ui/utils');
|
|
22
|
+
const { createTurnHandler } = require('../lib/commands/chat-turn');
|
|
23
|
+
const { buildToolOperation, serializeOperation } = require('../lib/ui/tool-operation');
|
|
24
|
+
const {
|
|
25
|
+
createFileActivityTracker, formatFileSummaryLine, fileSummaryState,
|
|
26
|
+
isGroupableFileCore, normalizeFileTag, fileGroupKey,
|
|
27
|
+
} = require('../lib/ui/file-activity');
|
|
28
|
+
const { ChatHistory } = require('../lib/ui/chat-history');
|
|
29
|
+
const { createChatSession } = require('../lib/commands/chat-session');
|
|
30
|
+
|
|
31
|
+
const CFG = { diff_max_lines: 50, shell_preview_lines: 5 };
|
|
32
|
+
|
|
33
|
+
// ── Live harness: drive the real createTurnHandler callbacks ──────────────────
|
|
34
|
+
// Records every committed line in ONE ordered log. The file summary / individual
|
|
35
|
+
// file lines commit via writerModule.endActivity (from fileTracker.flush); the
|
|
36
|
+
// answer via chatHistory.finalizeLastMessage; an error body via addMessage.
|
|
37
|
+
function harness(opts) {
|
|
38
|
+
const events = [];
|
|
39
|
+
const writerModule = {
|
|
40
|
+
startActivity() {}, updateActivity() {},
|
|
41
|
+
endActivity(id, line) {
|
|
42
|
+
// A flush may commit several individual lines joined by '\n' (the <3 case).
|
|
43
|
+
for (const raw of String(line == null ? '' : line).split('\n')) {
|
|
44
|
+
if (raw === '') continue;
|
|
45
|
+
events.push({ kind: 'commit', line: stripAnsi(raw) });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
scrollback(line) { events.push({ kind: 'scrollback', line: stripAnsi(String(line)) }); },
|
|
49
|
+
};
|
|
50
|
+
const chatHistory = {
|
|
51
|
+
addMessage(m) {
|
|
52
|
+
if (m && m.isError) events.push({ kind: 'error-body', output: m.output });
|
|
53
|
+
},
|
|
54
|
+
streamToken() {}, clearStreamingContent() {},
|
|
55
|
+
deferToolOutput() {}, commitDeferredDetail() {},
|
|
56
|
+
finalizeLastMessage(content) { if (content && content.trim()) events.push({ kind: 'answer', content }); },
|
|
57
|
+
};
|
|
58
|
+
const statusBar = { update() {}, onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {} };
|
|
59
|
+
const inputField = { on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {} };
|
|
60
|
+
|
|
61
|
+
let scenario = async () => {};
|
|
62
|
+
const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
|
|
63
|
+
await scenario(loopOpts.callbacks);
|
|
64
|
+
return { messages, metrics: { turns: [] }, withheldActions: [] };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const ctx = {
|
|
68
|
+
inputField, statusBar, chatHistory, writerModule, runAgentLoop,
|
|
69
|
+
getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
|
|
70
|
+
approxTokens: () => 0,
|
|
71
|
+
resolveCommand: () => null,
|
|
72
|
+
opts: {},
|
|
73
|
+
TAG_REGISTRY: {},
|
|
74
|
+
collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
|
|
75
|
+
activateNavCapture() {}, finalizeListMsg() {},
|
|
76
|
+
createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
|
|
77
|
+
messages: [], currentModel: 'm', debugMode: (opts && opts.debugMode) || false, pendingImages: [],
|
|
78
|
+
chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handler = createTurnHandler(ctx, {});
|
|
82
|
+
return { events, handler, setScenario: (fn) => { scenario = fn; } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Simulate one fully-successful groupable file op (one agent iteration's worth).
|
|
86
|
+
function fileOp(cb, tag, path, bytes) {
|
|
87
|
+
cb.onToolStart(tag, path, { id: `${tag}-${path}`, attrs: { path } });
|
|
88
|
+
cb.onToolEnd(tag, 'contents', 5, { id: `${tag}-${path}`, attrs: { path }, meta: { bytes: bytes || 10 }, error: null });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const commits = (events) => events.filter((e) => e.kind === 'commit');
|
|
92
|
+
// Matches a collapsed file summary for any verb: homogeneous (read/reading,
|
|
93
|
+
// list/listing) or the neutral mixed verb (file/accessing).
|
|
94
|
+
const summaries = (events) => commits(events).filter((e) => /file .*(read|reading|list|listing|file|accessing) ×\d+/.test(e.line));
|
|
95
|
+
|
|
96
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
97
|
+
// (a) 10 consecutive read_file ops → ONE "✓ file · read ×10 (…)" summary line.
|
|
98
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
99
|
+
test('(a) 10 reads collapse to ONE summary; ×10 always present; basenames truncated to width', async () => {
|
|
100
|
+
const prevCols = process.stdout.columns;
|
|
101
|
+
process.stdout.columns = 60; // narrow → force basename truncation
|
|
102
|
+
try {
|
|
103
|
+
const h = harness();
|
|
104
|
+
const files = Array.from({ length: 10 }, (_, i) => `/proj/src/file-${i}-with-a-long-name.js`);
|
|
105
|
+
h.setScenario(async (cb) => {
|
|
106
|
+
cb.onAssistantMessage(''); // one tool-call-only iteration
|
|
107
|
+
for (const f of files) fileOp(cb, 'read', f, 100 + 1);
|
|
108
|
+
cb.onAssistantMessage('Done reading.'); // terminal
|
|
109
|
+
});
|
|
110
|
+
await h.handler('warm up');
|
|
111
|
+
|
|
112
|
+
const s = summaries(h.events);
|
|
113
|
+
assert.strictEqual(s.length, 1, 'exactly one collapsed summary');
|
|
114
|
+
assert.match(s[0].line, /file .* read ×10 \(/, 'shows the read verb and the ×10 count');
|
|
115
|
+
assert.ok(s[0].line.includes('…'), 'the basename list is truncated to width');
|
|
116
|
+
assert.match(s[0].line, /×10/, 'the ×10 count survives truncation (it is in the fixed prefix)');
|
|
117
|
+
// Single physical row at the render width.
|
|
118
|
+
assert.ok(s[0].line.length <= 60, `summary fits one 60-col row (got ${s[0].line.length})`);
|
|
119
|
+
// No per-op read lines leaked alongside the summary.
|
|
120
|
+
assert.strictEqual(commits(h.events).filter((e) => /read \//.test(e.line)).length, 0, 'no individual read lines');
|
|
121
|
+
} finally {
|
|
122
|
+
process.stdout.columns = prevCols;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// (b) 2 read ops → two individual lines (threshold: <3 stays per-op).
|
|
128
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
129
|
+
test('(b) 2 reads commit as two individual lines, no summary', async () => {
|
|
130
|
+
const h = harness();
|
|
131
|
+
h.setScenario(async (cb) => {
|
|
132
|
+
cb.onAssistantMessage('');
|
|
133
|
+
fileOp(cb, 'read', '/a.js');
|
|
134
|
+
fileOp(cb, 'read', '/b.js');
|
|
135
|
+
cb.onAssistantMessage('done');
|
|
136
|
+
});
|
|
137
|
+
await h.handler('read two');
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(summaries(h.events).length, 0, 'no collapsed summary for a 2-op run');
|
|
140
|
+
const indiv = commits(h.events).filter((e) => /read \//.test(e.line));
|
|
141
|
+
assert.strictEqual(indiv.length, 2, 'two individual per-op result lines');
|
|
142
|
+
assert.match(indiv[0].line, /read \/a\.js/);
|
|
143
|
+
assert.match(indiv[1].line, /read \/b\.js/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// (c) reads and lists INTERLEAVED (read, list, read, list, read) → ONE merged
|
|
148
|
+
// summary with the neutral "file ×5" verb (was: two separate summaries —
|
|
149
|
+
// CHANGED by the key-merge: read_file + list_dir now share one group).
|
|
150
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
151
|
+
test('(c) interleaved reads+lists collapse to ONE merged summary with the neutral verb', async () => {
|
|
152
|
+
const h = harness();
|
|
153
|
+
h.setScenario(async (cb) => {
|
|
154
|
+
cb.onAssistantMessage('');
|
|
155
|
+
fileOp(cb, 'read', '/a.js');
|
|
156
|
+
fileOp(cb, 'list_dir', '/d0');
|
|
157
|
+
fileOp(cb, 'read', '/b.js');
|
|
158
|
+
fileOp(cb, 'list_dir', '/d1');
|
|
159
|
+
fileOp(cb, 'read', '/c.js');
|
|
160
|
+
cb.onAssistantMessage('done');
|
|
161
|
+
});
|
|
162
|
+
await h.handler('interleaved reads and lists');
|
|
163
|
+
|
|
164
|
+
const s = summaries(h.events);
|
|
165
|
+
assert.strictEqual(s.length, 1, 'one merged summary — read and list share a group now');
|
|
166
|
+
assert.match(s[0].line, /file .* file ×5 \(/, 'mixed group uses the neutral "file ×5" verb');
|
|
167
|
+
assert.doesNotMatch(s[0].line, /read ×|list ×/, 'no homogeneous verb for a mixed group');
|
|
168
|
+
// All five basenames/dirs listed once in the merged summary.
|
|
169
|
+
for (const b of ['a.js', 'd0', 'b.js', 'd1', 'c.js']) {
|
|
170
|
+
assert.ok(s[0].line.includes(b), `merged summary lists ${b}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
175
|
+
// (c2) homogeneous list run (5 list_dir, no reads) → still "list ×5".
|
|
176
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
177
|
+
test('(c2) 5 list_dir ops only → homogeneous "list ×5" summary (specific verb kept)', async () => {
|
|
178
|
+
const h = harness();
|
|
179
|
+
h.setScenario(async (cb) => {
|
|
180
|
+
cb.onAssistantMessage('');
|
|
181
|
+
for (let i = 0; i < 5; i++) fileOp(cb, 'list_dir', `/dir${i}`);
|
|
182
|
+
cb.onAssistantMessage('done');
|
|
183
|
+
});
|
|
184
|
+
await h.handler('five lists');
|
|
185
|
+
|
|
186
|
+
const s = summaries(h.events);
|
|
187
|
+
assert.strictEqual(s.length, 1, 'one summary');
|
|
188
|
+
assert.match(s[0].line, /list ×5/, 'homogeneous lists keep the specific "list" verb');
|
|
189
|
+
assert.doesNotMatch(s[0].line, /file ×|read ×/, 'no neutral/read verb for an all-list group');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// (c3) a mixed read/list run broken by a grep in the middle → TWO groups, split
|
|
194
|
+
// by the grep (a non-file tool still breaks the run; only read↔list merges).
|
|
195
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
196
|
+
test('(c3) a grep between two mixed read/list runs splits them into two summaries', async () => {
|
|
197
|
+
const h = harness();
|
|
198
|
+
h.setScenario(async (cb) => {
|
|
199
|
+
cb.onAssistantMessage('');
|
|
200
|
+
// group 1: read, list, read → mixed ×3
|
|
201
|
+
fileOp(cb, 'read', '/g1a.js');
|
|
202
|
+
fileOp(cb, 'list_dir', '/g1d');
|
|
203
|
+
fileOp(cb, 'read', '/g1b.js');
|
|
204
|
+
// a grep breaks the run.
|
|
205
|
+
cb.onToolStart('grep', 'TODO', { id: 'grep-1', attrs: { pattern: 'TODO' } });
|
|
206
|
+
cb.onToolEnd('grep', 'match', 7, { id: 'grep-1', attrs: { pattern: 'TODO' }, meta: { matches: 1 }, error: null });
|
|
207
|
+
// group 2: list, read, list → mixed ×3
|
|
208
|
+
fileOp(cb, 'list_dir', '/g2d');
|
|
209
|
+
fileOp(cb, 'read', '/g2a.js');
|
|
210
|
+
fileOp(cb, 'list_dir', '/g2e');
|
|
211
|
+
cb.onAssistantMessage('done');
|
|
212
|
+
});
|
|
213
|
+
await h.handler('mixed, grep, mixed');
|
|
214
|
+
|
|
215
|
+
const s = summaries(h.events);
|
|
216
|
+
assert.strictEqual(s.length, 2, 'the grep splits the run into two merged summaries');
|
|
217
|
+
assert.match(s[0].line, /file ×3/, 'first mixed group is file ×3');
|
|
218
|
+
assert.match(s[1].line, /file ×3/, 'second mixed group is file ×3');
|
|
219
|
+
const iS0 = h.events.findIndex((e) => e.kind === 'commit' && /file ×3/.test(e.line));
|
|
220
|
+
const iGrep = h.events.findIndex((e) => e.kind === 'commit' && /TODO/.test(e.line));
|
|
221
|
+
assert.ok(iS0 >= 0 && iGrep >= 0 && iS0 < iGrep, 'the first summary lands above the grep line');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// (c4) 2 mixed ops (1 read + 1 list) → individual per-op lines (threshold <3,
|
|
226
|
+
// unchanged — merging the key does NOT lower the threshold).
|
|
227
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
228
|
+
test('(c4) 1 read + 1 list → two individual lines, no merged summary (threshold 3)', async () => {
|
|
229
|
+
const h = harness();
|
|
230
|
+
h.setScenario(async (cb) => {
|
|
231
|
+
cb.onAssistantMessage('');
|
|
232
|
+
fileOp(cb, 'read', '/a.js');
|
|
233
|
+
fileOp(cb, 'list_dir', '/d');
|
|
234
|
+
cb.onAssistantMessage('done');
|
|
235
|
+
});
|
|
236
|
+
await h.handler('one read one list');
|
|
237
|
+
|
|
238
|
+
assert.strictEqual(summaries(h.events).length, 0, 'no collapsed summary for a 2-op run');
|
|
239
|
+
const indiv = commits(h.events).filter((e) => /read \/|list .*\/d/.test(e.line));
|
|
240
|
+
const readLine = commits(h.events).find((e) => /read \/a\.js/.test(e.line));
|
|
241
|
+
const listLine = commits(h.events).find((e) => /\/d/.test(e.line) && !/read/.test(e.line));
|
|
242
|
+
assert.ok(readLine, 'the read commits its own per-op line');
|
|
243
|
+
assert.ok(listLine, 'the list commits its own per-op line');
|
|
244
|
+
assert.strictEqual(indiv.length, 2, 'exactly two individual per-op lines');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// (d) read run interrupted by a non-file tool → group flushes before the tool row.
|
|
249
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
250
|
+
test('(d) a non-file tool after a read run flushes the summary before its own line', async () => {
|
|
251
|
+
const h = harness();
|
|
252
|
+
h.setScenario(async (cb) => {
|
|
253
|
+
cb.onAssistantMessage('');
|
|
254
|
+
for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
|
|
255
|
+
// A shell op breaks the group: its onToolStart closes the file group first.
|
|
256
|
+
cb.onToolStart('shell', 'ls -la', { id: 'sh-1', attrs: { command: 'ls -la' } });
|
|
257
|
+
cb.onToolEnd('shell', 'Command `ls -la`:\nExit code: 0\nout', 9, { id: 'sh-1', attrs: { command: 'ls -la' }, meta: { exit_code: 0 }, error: null });
|
|
258
|
+
cb.onAssistantMessage('done');
|
|
259
|
+
});
|
|
260
|
+
await h.handler('read then shell');
|
|
261
|
+
|
|
262
|
+
const iSummary = h.events.findIndex((e) => e.kind === 'commit' && /read ×3/.test(e.line));
|
|
263
|
+
const iShell = h.events.findIndex((e) => e.kind === 'commit' && /ls -la/.test(e.line));
|
|
264
|
+
assert.ok(iSummary >= 0, 'the read summary committed');
|
|
265
|
+
assert.ok(iShell >= 0, 'the shell line committed');
|
|
266
|
+
assert.ok(iSummary < iShell, 'the read summary lands ABOVE the shell line');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// (e) read run with op #5 erroring → "read ×4" summary, then standalone error +
|
|
271
|
+
// body, then a fresh group for the subsequent reads.
|
|
272
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
273
|
+
test('(e) a mid-run error flushes the success-group, renders error standalone, then a new group starts', async () => {
|
|
274
|
+
const h = harness();
|
|
275
|
+
h.setScenario(async (cb) => {
|
|
276
|
+
cb.onAssistantMessage('');
|
|
277
|
+
for (let i = 0; i < 4; i++) fileOp(cb, 'read', `/ok${i}.js`);
|
|
278
|
+
// op #5 errors — must NOT join the group.
|
|
279
|
+
cb.onToolStart('read', '/bad.js', { id: 'read-bad', attrs: { path: '/bad.js' } });
|
|
280
|
+
cb.onToolEnd('read', 'Error: boom', 3, { id: 'read-bad', attrs: { path: '/bad.js' }, meta: null, error: { message: 'boom' } });
|
|
281
|
+
// three more reads → a brand new group.
|
|
282
|
+
for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/more${i}.js`);
|
|
283
|
+
cb.onAssistantMessage('done');
|
|
284
|
+
});
|
|
285
|
+
await h.handler('reads with an error');
|
|
286
|
+
|
|
287
|
+
const s = summaries(h.events);
|
|
288
|
+
assert.strictEqual(s.length, 2, 'the 4 successes and the 3 later successes form two summaries');
|
|
289
|
+
assert.match(s[0].line, /read ×4/, 'the errored op did NOT join the group → ×4 not ×5');
|
|
290
|
+
assert.match(s[1].line, /read ×3/, 'a new group started after the error');
|
|
291
|
+
|
|
292
|
+
const iSummary4 = h.events.findIndex((e) => e.kind === 'commit' && /read ×4/.test(e.line));
|
|
293
|
+
const iErrLine = h.events.findIndex((e) => e.kind === 'commit' && /read \/bad\.js/.test(e.line));
|
|
294
|
+
const iErrBody = h.events.findIndex((e) => e.kind === 'error-body');
|
|
295
|
+
const iSummary3 = h.events.findIndex((e) => e.kind === 'commit' && /read ×3/.test(e.line));
|
|
296
|
+
assert.ok(iSummary4 >= 0 && iErrLine >= 0 && iErrBody >= 0 && iSummary3 >= 0, 'all four landmarks present');
|
|
297
|
+
assert.ok(iSummary4 < iErrLine, 'success summary before the error line (never above the reads it followed)');
|
|
298
|
+
assert.ok(iErrLine < iErrBody, 'error line before its expandable body');
|
|
299
|
+
assert.ok(iErrBody < iSummary3, 'the new group commits after the error');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// (f) multi-iteration read run with intermediate narration → still ONE summary
|
|
304
|
+
// (terminal-flag gating: intermediate narration must NOT split the group).
|
|
305
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
306
|
+
test('(f) intermediate-iteration narration does NOT split a multi-iteration read run', async () => {
|
|
307
|
+
const h = harness();
|
|
308
|
+
h.setScenario(async (cb) => {
|
|
309
|
+
// iter 1: two reads, then a NON-empty but NON-terminal narration.
|
|
310
|
+
cb.onAssistantMessage('');
|
|
311
|
+
fileOp(cb, 'read', '/i1a.js');
|
|
312
|
+
fileOp(cb, 'read', '/i1b.js');
|
|
313
|
+
cb.onAssistantMessage('Let me read a couple more files.', { terminal: false });
|
|
314
|
+
// iter 2: two more reads, then the terminal answer.
|
|
315
|
+
fileOp(cb, 'read', '/i2a.js');
|
|
316
|
+
fileOp(cb, 'read', '/i2b.js');
|
|
317
|
+
cb.onAssistantMessage('All read.', { terminal: true });
|
|
318
|
+
});
|
|
319
|
+
await h.handler('multi-iteration reads');
|
|
320
|
+
|
|
321
|
+
const s = summaries(h.events);
|
|
322
|
+
assert.strictEqual(s.length, 1, 'the four reads across two iterations collapse to ONE summary');
|
|
323
|
+
assert.match(s[0].line, /read ×4/, 'all four reads counted');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
327
|
+
// (g) replay/resume re-groups to the same summary at the REPLAY width; a grouped
|
|
328
|
+
// run replayed narrower re-truncates; the >=3 threshold is applied on replay.
|
|
329
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
// Drive the REAL tracker as the live oracle at a fixed width.
|
|
332
|
+
function liveFileSummary(ops, cols) {
|
|
333
|
+
const prev = process.stdout.columns;
|
|
334
|
+
process.stdout.columns = cols;
|
|
335
|
+
try {
|
|
336
|
+
const lines = [];
|
|
337
|
+
const tracker = createFileActivityTracker({ writerModule: {
|
|
338
|
+
startActivity: () => {}, updateActivity: () => {},
|
|
339
|
+
endActivity: (_id, line) => lines.push(line),
|
|
340
|
+
} });
|
|
341
|
+
for (const op of ops) { tracker.start(op.tag, op.target); tracker.end(op); }
|
|
342
|
+
tracker.flush();
|
|
343
|
+
return lines;
|
|
344
|
+
} finally {
|
|
345
|
+
process.stdout.columns = prev;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function replayCommits(loadedMessages, cols) {
|
|
350
|
+
const prev = process.stdout.columns;
|
|
351
|
+
process.stdout.columns = cols;
|
|
352
|
+
try {
|
|
353
|
+
const ch = new ChatHistory();
|
|
354
|
+
const out = [];
|
|
355
|
+
ch._commit = (t) => out.push(t);
|
|
356
|
+
const session = createChatSession({ chatHistory: ch, getConfig: () => CFG });
|
|
357
|
+
session.displayLoadedMessages(loadedMessages);
|
|
358
|
+
return out;
|
|
359
|
+
} finally {
|
|
360
|
+
process.stdout.columns = prev;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const fileLineOf = (commitsArr) => commitsArr
|
|
365
|
+
.map((c) => stripAnsi(c))
|
|
366
|
+
.filter((c) => /file .* read ×\d+/.test(c));
|
|
367
|
+
|
|
368
|
+
test('(g) replay re-groups to the same summary at the replay width; narrower re-truncates; ≥3 threshold applied', () => {
|
|
369
|
+
const files = Array.from({ length: 6 }, (_, i) => `/proj/module-${i}/index-file-${i}.js`);
|
|
370
|
+
const ops = files.map((f) => buildToolOperation({ id: `r${f}`, tag: 'read', arg: f, attrs: { path: f }, status: 'ok', durationMs: 5, meta: { bytes: 200 } }));
|
|
371
|
+
|
|
372
|
+
// Persist exactly as the live path does — one native {role:'tool'} message per op
|
|
373
|
+
// carrying the normal serialized core (NO storage format change).
|
|
374
|
+
const loaded = ops.map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
|
|
375
|
+
.concat([{ role: 'assistant', content: 'done' }]);
|
|
376
|
+
|
|
377
|
+
// Same width: replay byte-identical to the live committed summary.
|
|
378
|
+
const oracle200 = liveFileSummary(ops, 200);
|
|
379
|
+
assert.strictEqual(oracle200.length, 1, 'live commits one summary for 6 reads');
|
|
380
|
+
const replay200 = fileLineOf(replayCommits(loaded, 200));
|
|
381
|
+
assert.strictEqual(replay200.length, 1, 'replay commits exactly one file summary');
|
|
382
|
+
assert.strictEqual(replay200[0], stripAnsi(oracle200[0]), 'replay summary is byte-identical to the live one at the same width');
|
|
383
|
+
|
|
384
|
+
// Narrower terminal: re-truncates at 80 cols, but the ×6 count still shows.
|
|
385
|
+
const replay80 = fileLineOf(replayCommits(loaded, 80));
|
|
386
|
+
assert.strictEqual(replay80.length, 1, 'one summary at 80 cols too');
|
|
387
|
+
assert.match(replay80[0], /×6/, 'the ×6 count survives the narrower re-truncation');
|
|
388
|
+
assert.ok(replay80[0].length <= 80, 'fits one 80-col row');
|
|
389
|
+
assert.notStrictEqual(replay80[0], replay200[0], 'the 80-col render re-truncates differently from the 200-col one');
|
|
390
|
+
|
|
391
|
+
// Threshold on replay: a 2-op run replays as individual lines, no summary.
|
|
392
|
+
const twoLoaded = ops.slice(0, 2).map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
|
|
393
|
+
.concat([{ role: 'assistant', content: 'done' }]);
|
|
394
|
+
const replayTwo = replayCommits(twoLoaded, 200);
|
|
395
|
+
assert.strictEqual(fileLineOf(replayTwo).length, 0, 'a 2-op run does NOT collapse on replay');
|
|
396
|
+
assert.strictEqual(replayTwo.filter((c) => /read \//.test(stripAnsi(c))).length, 2, 'it replays as two individual read lines');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
400
|
+
// (g2) replay of a MIXED read/list run re-groups into the SAME single merged
|
|
401
|
+
// summary (neutral "file ×N" verb), byte-identical to the live oracle.
|
|
402
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
403
|
+
test('(g2) replay of a mixed read/list run → identical merged summary at the replay width', () => {
|
|
404
|
+
// read, list, read, list, read — interleaved, persisted as native cores.
|
|
405
|
+
const seq = [
|
|
406
|
+
{ tag: 'read', path: '/proj/src/alpha-module.js' },
|
|
407
|
+
{ tag: 'list_dir', path: '/proj/src/components' },
|
|
408
|
+
{ tag: 'read', path: '/proj/src/beta-module.js' },
|
|
409
|
+
{ tag: 'list_dir', path: '/proj/src/utils' },
|
|
410
|
+
{ tag: 'read', path: '/proj/src/gamma-module.js' },
|
|
411
|
+
];
|
|
412
|
+
const ops = seq.map((o, i) => buildToolOperation({
|
|
413
|
+
id: `m${i}`, tag: o.tag, arg: o.path, attrs: { path: o.path },
|
|
414
|
+
status: 'ok', durationMs: 5, meta: o.tag === 'read' ? { bytes: 200 } : { entries: 3 },
|
|
415
|
+
}));
|
|
416
|
+
const loaded = ops.map((op) => ({ role: 'tool', content: 'contents', _display: serializeOperation(op) }))
|
|
417
|
+
.concat([{ role: 'assistant', content: 'done' }]);
|
|
418
|
+
|
|
419
|
+
const mixedLineOf = (commitsArr) => commitsArr
|
|
420
|
+
.map((c) => stripAnsi(c))
|
|
421
|
+
.filter((c) => /file .* file ×\d+/.test(c));
|
|
422
|
+
|
|
423
|
+
const oracle = liveFileSummary(ops, 200);
|
|
424
|
+
assert.strictEqual(oracle.length, 1, 'live commits one merged summary for the mixed run');
|
|
425
|
+
assert.match(stripAnsi(oracle[0]), /file ×5/, 'live oracle uses the neutral verb for the mixed run');
|
|
426
|
+
|
|
427
|
+
const replay = mixedLineOf(replayCommits(loaded, 200));
|
|
428
|
+
assert.strictEqual(replay.length, 1, 'replay commits exactly one merged file summary');
|
|
429
|
+
assert.strictEqual(replay[0], stripAnsi(oracle[0]), 'replay merged summary is byte-identical to the live one');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
433
|
+
// (h) double-flush guard — the boundary flush + the turn-end finally must commit
|
|
434
|
+
// a group EXACTLY ONCE (endActivity called once per group).
|
|
435
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
436
|
+
test('(h) double-flush guard: a group commits via endActivity exactly once', () => {
|
|
437
|
+
let endCalls = 0;
|
|
438
|
+
const tracker = createFileActivityTracker({ writerModule: {
|
|
439
|
+
startActivity: () => {}, updateActivity: () => {},
|
|
440
|
+
endActivity: () => { endCalls++; },
|
|
441
|
+
} });
|
|
442
|
+
const op = buildToolOperation({ id: 'r', tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'ok', durationMs: 5, meta: { bytes: 10 } });
|
|
443
|
+
tracker.start('read', '/a.js');
|
|
444
|
+
tracker.end(op);
|
|
445
|
+
assert.ok(tracker.isOpen(), 'group open after one op');
|
|
446
|
+
tracker.flush(); // boundary flush
|
|
447
|
+
assert.strictEqual(tracker.isOpen(), false, 'closed after flush');
|
|
448
|
+
tracker.flush(); // finally flush — must be a no-op
|
|
449
|
+
tracker.flush();
|
|
450
|
+
assert.strictEqual(endCalls, 1, 'endActivity called exactly once despite three flush() calls');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
454
|
+
// (i) the web tracker is UNAFFECTED — a web run interleaved with a file run in
|
|
455
|
+
// one turn still produces its own web summary, untouched.
|
|
456
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
457
|
+
test('(i) the web tracker is unaffected: a web run alongside a file run still yields a web summary', async () => {
|
|
458
|
+
const h = harness();
|
|
459
|
+
h.setScenario(async (cb) => {
|
|
460
|
+
cb.onAssistantMessage('');
|
|
461
|
+
for (let i = 0; i < 3; i++) fileOp(cb, 'read', `/r${i}.js`);
|
|
462
|
+
// A web op breaks the file group and opens a web group.
|
|
463
|
+
cb.onToolStart('http_get', 'https://x.example', { id: 'g1', attrs: { url: 'https://x.example' } });
|
|
464
|
+
cb.onToolEnd('http_get', {}, 120, { id: 'g1', attrs: { url: 'https://x.example' }, meta: { status_code: 200, bytes: 1000 }, error: null });
|
|
465
|
+
cb.onAssistantMessage('done');
|
|
466
|
+
});
|
|
467
|
+
await h.handler('reads then a fetch');
|
|
468
|
+
|
|
469
|
+
const fileS = summaries(h.events);
|
|
470
|
+
assert.strictEqual(fileS.length, 1, 'one file summary');
|
|
471
|
+
assert.match(fileS[0].line, /read ×3/);
|
|
472
|
+
const webS = commits(h.events).filter((e) => / web /.test(e.line) && /source/.test(e.line));
|
|
473
|
+
assert.strictEqual(webS.length, 1, 'the web tracker still commits its own summary, unaffected');
|
|
474
|
+
const iFile = h.events.findIndex((e) => e.kind === 'commit' && /read ×3/.test(e.line));
|
|
475
|
+
const iWeb = h.events.findIndex((e) => e.kind === 'commit' && / web /.test(e.line) && /source/.test(e.line));
|
|
476
|
+
assert.ok(iFile < iWeb, 'the file summary lands above the web summary it preceded');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
480
|
+
// Pure-function spot checks: core/key predicates the replay path depends on.
|
|
481
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
482
|
+
test('isGroupableFileCore / normalizeFileTag / fileSummaryState predicates', () => {
|
|
483
|
+
const readCore = serializeOperation(buildToolOperation({ tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'ok' }));
|
|
484
|
+
const listCore = serializeOperation(buildToolOperation({ tag: 'list_dir', arg: '/d', attrs: { path: '/d' }, status: 'ok' }));
|
|
485
|
+
const shellCore = serializeOperation(buildToolOperation({ tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, status: 'ok' }));
|
|
486
|
+
const errCore = serializeOperation(buildToolOperation({ tag: 'read', arg: '/a.js', attrs: { path: '/a.js' }, status: 'error', error: { message: 'x' } }));
|
|
487
|
+
|
|
488
|
+
assert.ok(isGroupableFileCore(readCore), 'a successful read core is groupable');
|
|
489
|
+
assert.ok(isGroupableFileCore(listCore), 'a successful list_dir core is groupable');
|
|
490
|
+
assert.ok(!isGroupableFileCore(shellCore), 'a shell core is not groupable');
|
|
491
|
+
assert.ok(!isGroupableFileCore(errCore), 'an errored read core is not groupable');
|
|
492
|
+
assert.ok(!isGroupableFileCore({ v: 1, kind: 'web', tag: 'http_get' }), 'a web core is not a file core');
|
|
493
|
+
assert.ok(!isGroupableFileCore(null), 'null is tolerated');
|
|
494
|
+
|
|
495
|
+
// read_file and list_dir normalize to DISTINCT tags (used by fileSummaryState
|
|
496
|
+
// to decide the verb) …
|
|
497
|
+
assert.notStrictEqual(normalizeFileTag(readCore.tag), normalizeFileTag(listCore.tag));
|
|
498
|
+
assert.strictEqual(normalizeFileTag('read'), 'read_file');
|
|
499
|
+
assert.strictEqual(normalizeFileTag('list_dir'), 'list_dir');
|
|
500
|
+
// … but they now share ONE group KEY, so a read↔list switch never flushes.
|
|
501
|
+
assert.strictEqual(fileGroupKey('read'), fileGroupKey('list_dir'));
|
|
502
|
+
assert.strictEqual(fileGroupKey('read_file'), fileGroupKey('list_dir'));
|
|
503
|
+
|
|
504
|
+
// Homogeneous read group → specific "read" verb.
|
|
505
|
+
const st = fileSummaryState([readCore, readCore]);
|
|
506
|
+
assert.strictEqual(st.verb, 'read');
|
|
507
|
+
assert.strictEqual(st.gerund, 'reading…');
|
|
508
|
+
assert.strictEqual(st.count, 2);
|
|
509
|
+
assert.deepStrictEqual(st.basenames, ['a.js', 'a.js']);
|
|
510
|
+
|
|
511
|
+
// Homogeneous list group → specific "list" verb.
|
|
512
|
+
const stList = fileSummaryState([listCore, listCore]);
|
|
513
|
+
assert.strictEqual(stList.verb, 'list');
|
|
514
|
+
assert.strictEqual(stList.gerund, 'listing…');
|
|
515
|
+
|
|
516
|
+
// Mixed group → neutral "file" / "accessing…" verb.
|
|
517
|
+
const stMixed = fileSummaryState([readCore, listCore]);
|
|
518
|
+
assert.strictEqual(stMixed.verb, 'file');
|
|
519
|
+
assert.strictEqual(stMixed.gerund, 'accessing…');
|
|
520
|
+
assert.strictEqual(stMixed.count, 2);
|
|
521
|
+
assert.deepStrictEqual(stMixed.basenames, ['a.js', 'd']);
|
|
522
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sample model messages for extractToolCalls characterization tests (Task 1.1).
|
|
4
|
+
// These capture the real shapes various model families emit. Kept as a module
|
|
5
|
+
// (not JSON) so multi-line template literals stay readable.
|
|
6
|
+
|
|
7
|
+
// A single assistant message mixing prose with multiple XML tool tags.
|
|
8
|
+
const MULTI_TAG_MESSAGE = [
|
|
9
|
+
'Sure, let me do a few things.',
|
|
10
|
+
'<read_file>src/index.js</read_file>',
|
|
11
|
+
'Now I will write the result:',
|
|
12
|
+
'<write_file path="out.txt">hello\nworld</write_file>',
|
|
13
|
+
'And run the tests:',
|
|
14
|
+
'<exec>npm test</exec>',
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
// MiniMax-M2 native wrapper round-tripped back into text by chatStream.
|
|
18
|
+
const MINIMAX_WRAPPER = [
|
|
19
|
+
'<minimax:tool_call>',
|
|
20
|
+
'<invoke name="write_file">',
|
|
21
|
+
'<parameter name="path">a.json</parameter>',
|
|
22
|
+
'<parameter name="content">{"k":1}</parameter>',
|
|
23
|
+
'</invoke>',
|
|
24
|
+
'</minimax:tool_call>',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
// Qwen3-Coder XML format: name on the tag as `=name`, params as `=key`.
|
|
28
|
+
const QWEN3_XML = [
|
|
29
|
+
'<function=write_file>',
|
|
30
|
+
'<parameter=path>a.css</parameter>',
|
|
31
|
+
'<parameter=content>body{}</parameter>',
|
|
32
|
+
'</function>',
|
|
33
|
+
].join('\n');
|
|
34
|
+
|
|
35
|
+
// Hermes/Qwen JSON tool-call block.
|
|
36
|
+
const JSON_TOOL_CALL = [
|
|
37
|
+
'<tool_call>',
|
|
38
|
+
'{"name": "read_file", "arguments": {"path": "README.md"}}',
|
|
39
|
+
'</tool_call>',
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
// A fenced shell block (models sometimes emit commands this way).
|
|
43
|
+
const SHELL_FENCE = [
|
|
44
|
+
'```shell',
|
|
45
|
+
'echo hi',
|
|
46
|
+
'# a comment line that must be skipped',
|
|
47
|
+
'ls -la',
|
|
48
|
+
'```',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
MULTI_TAG_MESSAGE,
|
|
53
|
+
MINIMAX_WRAPPER,
|
|
54
|
+
QWEN3_XML,
|
|
55
|
+
JSON_TOOL_CALL,
|
|
56
|
+
SHELL_FENCE,
|
|
57
|
+
};
|