@semalt-ai/code 1.8.5 → 1.19.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 +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- 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 +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -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 +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -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 +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- 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 +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- 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/background.test.js +414 -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/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -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/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -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 +203 -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/max-iterations.test.js +216 -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/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -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 +163 -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/result-cap.test.js +233 -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-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Characterization tests for extractToolCalls + its helpers (Task 1.1).
|
|
4
|
+
// These lock in CURRENT behavior — including quirks — so later refactors
|
|
5
|
+
// (Task 1.4 tool registry) cannot silently change parsing. Source is treated
|
|
6
|
+
// as the spec: where behavior is surprising, the test documents it rather than
|
|
7
|
+
// asserting what "should" happen.
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
extractToolCalls,
|
|
14
|
+
mapInvokeToCall,
|
|
15
|
+
repairMinimaxMalformedXml,
|
|
16
|
+
} = require('../lib/tools');
|
|
17
|
+
const fx = require('./fixtures/tool-calls');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// One case per inline-content tag (value is the tag body, trimmed/unwrapped).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const INLINE_TAG_CASES = [
|
|
24
|
+
{ name: 'shell', text: '<shell>ls -la</shell>', expected: [['shell', 'ls -la']] },
|
|
25
|
+
{ name: 'exec', text: '<exec>npm test</exec>', expected: [['shell', 'npm test']] },
|
|
26
|
+
{ name: 'run_command', text: '<run_command>make</run_command>', expected: [['shell', 'make']] },
|
|
27
|
+
{ name: 'run', text: '<run>go build</run>', expected: [['shell', 'go build']] },
|
|
28
|
+
// Task W.7: the read tuple gained start_line/end_line/show_line_numbers; absent
|
|
29
|
+
// range → null, show_line_numbers → false (parity with the native rail).
|
|
30
|
+
{ name: 'read_file', text: '<read_file>a.txt</read_file>', expected: [['read', 'a.txt', null, null, false]] },
|
|
31
|
+
{ name: 'list_dir', text: '<list_dir>src</list_dir>', expected: [['list_dir', 'src']] },
|
|
32
|
+
{ name: 'search_files', text: '<search_files>*.js</search_files>', expected: [['search_files', '*.js', '.']] },
|
|
33
|
+
{ name: 'delete_file', text: '<delete_file>x.tmp</delete_file>', expected: [['delete_file', 'x.tmp']] },
|
|
34
|
+
{ name: 'make_dir', text: '<make_dir>build</make_dir>', expected: [['make_dir', 'build']] },
|
|
35
|
+
{ name: 'remove_dir', text: '<remove_dir>build</remove_dir>', expected: [['remove_dir', 'build']] },
|
|
36
|
+
{ name: 'get_env', text: '<get_env>PATH</get_env>', expected: [['get_env', 'PATH']] },
|
|
37
|
+
{ name: 'download', text: '<download>http://x/f.zip</download>', expected: [['download', 'http://x/f.zip']] },
|
|
38
|
+
{ name: 'file_stat', text: '<file_stat>a.txt</file_stat>', expected: [['file_stat', 'a.txt']] },
|
|
39
|
+
{ name: 'http_get inline', text: '<http_get>http://x/api</http_get>', expected: [['http_get', 'http://x/api', {}]] },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const c of INLINE_TAG_CASES) {
|
|
43
|
+
test(`inline tag: ${c.name}`, () => {
|
|
44
|
+
assert.deepStrictEqual(extractToolCalls(c.text), c.expected);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// One case per attribute-form tag. Each is asserted in BOTH quote styles to
|
|
50
|
+
// cover the _matchDual single/double-quote compilation.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const ATTR_TAG_CASES = [
|
|
54
|
+
{ name: 'read_file path', tmpl: (q) => `<read_file path=${q}a.txt${q}/>`, expected: [['read', 'a.txt', null, null, false]] },
|
|
55
|
+
{ name: 'write_file', tmpl: (q) => `<write_file path=${q}a.txt${q}>hi</write_file>`, expected: [['write', 'a.txt', 'hi']] },
|
|
56
|
+
{ name: 'create_file→write', tmpl: (q) => `<create_file path=${q}a.txt${q}>hi</create_file>`, expected: [['write', 'a.txt', 'hi']] },
|
|
57
|
+
{ name: 'append_file', tmpl: (q) => `<append_file path=${q}a.txt${q}>more</append_file>`, expected: [['append', 'a.txt', 'more']] },
|
|
58
|
+
{ name: 'search_files attr', tmpl: (q) => `<search_files pattern=${q}*.ts${q} dir=${q}src${q}/>`, expected: [['search_files', '*.ts', 'src']] },
|
|
59
|
+
{ name: 'set_env', tmpl: (q) => `<set_env name=${q}FOO${q} value=${q}bar${q}/>`, expected: [['set_env', 'FOO', 'bar']] },
|
|
60
|
+
{ name: 'move_file', tmpl: (q) => `<move_file src=${q}a${q} dst=${q}b${q}/>`, expected: [['move_file', 'a', 'b']] },
|
|
61
|
+
{ name: 'copy_file', tmpl: (q) => `<copy_file src=${q}a${q} dst=${q}b${q}/>`, expected: [['copy_file', 'a', 'b']] },
|
|
62
|
+
{ name: 'edit_file', tmpl: (q) => `<edit_file path=${q}a.js${q} line=${q}42${q}>x = 1</edit_file>`, expected: [['edit_file', 'a.js', 42, 'x = 1']] },
|
|
63
|
+
{ name: 'search_in_file', tmpl: (q) => `<search_in_file path=${q}a.js${q}>TODO</search_in_file>`, expected: [['search_in_file', 'a.js', 'TODO']] },
|
|
64
|
+
{ name: 'replace_in_file', tmpl: (q) => `<replace_in_file path=${q}a.js${q} search=${q}old${q} replace=${q}new${q}>g</replace_in_file>`, expected: [['replace_in_file', 'a.js', 'old', 'new', 'g']] },
|
|
65
|
+
{ name: 'upload', tmpl: (q) => `<upload path=${q}a.bin${q}>QUJD</upload>`, expected: [['upload', 'a.bin', 'QUJD']] },
|
|
66
|
+
{ name: 'http_get attr', tmpl: (q) => `<http_get url=${q}http://x/api${q}/>`, expected: [['http_get', 'http://x/api', {}]] },
|
|
67
|
+
{ name: 'ask_user', tmpl: (q) => `<ask_user question=${q}Which lang?${q}/>`, expected: [['ask_user', 'Which lang?']] },
|
|
68
|
+
{ name: 'store_memory', tmpl: (q) => `<store_memory key=${q}lang${q}>TypeScript</store_memory>`, expected: [['store_memory', 'lang', 'TypeScript']] },
|
|
69
|
+
{ name: 'recall_memory', tmpl: (q) => `<recall_memory key=${q}lang${q}/>`, expected: [['recall_memory', 'lang']] },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const c of ATTR_TAG_CASES) {
|
|
73
|
+
test(`attr tag (double quotes): ${c.name}`, () => {
|
|
74
|
+
assert.deepStrictEqual(extractToolCalls(c.tmpl('"')), c.expected);
|
|
75
|
+
});
|
|
76
|
+
test(`attr tag (single quotes): ${c.name}`, () => {
|
|
77
|
+
assert.deepStrictEqual(extractToolCalls(c.tmpl("'")), c.expected);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Zero-argument self-closing tags.
|
|
82
|
+
test('zero-arg tag: list_memories', () => {
|
|
83
|
+
assert.deepStrictEqual(extractToolCalls('<list_memories/>'), [['list_memories']]);
|
|
84
|
+
assert.deepStrictEqual(extractToolCalls('<list_memories></list_memories>'), [['list_memories']]);
|
|
85
|
+
});
|
|
86
|
+
test('zero-arg tag: system_info', () => {
|
|
87
|
+
assert.deepStrictEqual(extractToolCalls('<system_info/>'), [['system_info']]);
|
|
88
|
+
assert.deepStrictEqual(extractToolCalls('<system_info></system_info>'), [['system_info']]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Native / wrapper formats and the JSON path.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
test('native: minimax:tool_call wrapper → write call', () => {
|
|
96
|
+
assert.deepStrictEqual(extractToolCalls(fx.MINIMAX_WRAPPER), [['write', 'a.json', '{"k":1}']]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('native: qwen:tool_call wrapper', () => {
|
|
100
|
+
const text = fx.MINIMAX_WRAPPER.replace(/minimax:/g, 'qwen:');
|
|
101
|
+
assert.deepStrictEqual(extractToolCalls(text), [['write', 'a.json', '{"k":1}']]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('native: Qwen3 <function=…> XML', () => {
|
|
105
|
+
assert.deepStrictEqual(extractToolCalls(fx.QWEN3_XML), [['write', 'a.css', 'body{}']]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('native: JSON <tool_call> block', () => {
|
|
109
|
+
assert.deepStrictEqual(extractToolCalls(fx.JSON_TOOL_CALL), [['read', 'README.md', null, null, false]]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('native: <function_call> JSON with `parameters` key', () => {
|
|
113
|
+
const text = '<function_call>{"name":"make_dir","parameters":{"path":"build"}}</function_call>';
|
|
114
|
+
assert.deepStrictEqual(extractToolCalls(text), [['make_dir', 'build']]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('native: JSON array of tool calls', () => {
|
|
118
|
+
const text = '<tool_call>[{"name":"read_file","arguments":{"path":"a"}},{"name":"read_file","arguments":{"path":"b"}}]</tool_call>';
|
|
119
|
+
assert.deepStrictEqual(extractToolCalls(text), [['read', 'a', null, null, false], ['read', 'b', null, null, false]]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('native: <tool_call> wrapping <invoke>', () => {
|
|
123
|
+
const text = '<tool_call>\n<invoke name="delete_file">\n<parameter name="path">x.tmp</parameter>\n</invoke>\n</tool_call>';
|
|
124
|
+
assert.deepStrictEqual(extractToolCalls(text), [['delete_file', 'x.tmp']]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('native: JSON with stringified arguments is re-parsed', () => {
|
|
128
|
+
const text = '<tool_call>{"name":"read_file","arguments":"{\\"path\\":\\"a.txt\\"}"}</tool_call>';
|
|
129
|
+
assert.deepStrictEqual(extractToolCalls(text), [['read', 'a.txt', null, null, false]]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('native: JSON embedded in surrounding prose (brace-walker recovery)', () => {
|
|
133
|
+
const text = '<tool_call>Here you go: {"name":"make_dir","arguments":{"path":"d"}} done</tool_call>';
|
|
134
|
+
assert.deepStrictEqual(extractToolCalls(text), [['make_dir', 'd']]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('fenced shell block: each non-comment line becomes a shell call', () => {
|
|
138
|
+
assert.deepStrictEqual(extractToolCalls(fx.SHELL_FENCE), [['shell', 'echo hi'], ['shell', 'ls -la']]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Multiple calls, ordering, nesting, equivalence.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
test('multiple tool calls in one message', () => {
|
|
146
|
+
const calls = extractToolCalls(fx.MULTI_TAG_MESSAGE);
|
|
147
|
+
assert.deepStrictEqual(calls.sort(), [
|
|
148
|
+
['read', 'src/index.js', null, null, false],
|
|
149
|
+
['shell', 'npm test'],
|
|
150
|
+
['write', 'out.txt', 'hello\nworld'],
|
|
151
|
+
].sort());
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('QUIRK: parse order is by format group, not document order', () => {
|
|
155
|
+
// The wrapper/native pass runs before the inline-XML pass, so a wrapper that
|
|
156
|
+
// appears AFTER an inline tag in the text still lands first in the result.
|
|
157
|
+
const text = '<read_file>first.txt</read_file>\n' + fx.MINIMAX_WRAPPER;
|
|
158
|
+
assert.deepStrictEqual(extractToolCalls(text), [
|
|
159
|
+
['write', 'a.json', '{"k":1}'], // minimax wrapper — parsed first
|
|
160
|
+
['read', 'first.txt', null, null, false], // inline read — parsed later
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('nested XML-ish content inside write_file is preserved verbatim', () => {
|
|
165
|
+
const text = '<write_file path="page.html"><div>hi</div></write_file>';
|
|
166
|
+
assert.deepStrictEqual(extractToolCalls(text), [['write', 'page.html', '<div>hi</div>']]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('QUIRK: attribute-form content is NOT trimmed (unlike inline tags)', () => {
|
|
170
|
+
const text = '<write_file path="a.txt">\n spaced \n</write_file>';
|
|
171
|
+
assert.deepStrictEqual(extractToolCalls(text), [['write', 'a.txt', '\n spaced \n']]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('inline-tag body IS trimmed and unwrapped from a nested pseudo-tag', () => {
|
|
175
|
+
assert.deepStrictEqual(extractToolCalls('<list_dir> src/ </list_dir>'), [['list_dir', 'src/']]);
|
|
176
|
+
assert.deepStrictEqual(extractToolCalls('<list_dir><path>/tmp/foo</path></list_dir>'), [['list_dir', '/tmp/foo']]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('native and inline paths produce equivalent structured calls for write_file', () => {
|
|
180
|
+
const viaInline = extractToolCalls('<write_file path="a.json">{"k":1}</write_file>');
|
|
181
|
+
const viaNative = extractToolCalls(fx.MINIMAX_WRAPPER);
|
|
182
|
+
assert.deepStrictEqual(viaInline, viaNative);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Malformed / partial input — current behavior is "emit nothing", not throw.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const MALFORMED_CASES = [
|
|
190
|
+
{ name: 'unclosed write_file', text: '<write_file path="a.txt">no closing tag' },
|
|
191
|
+
{ name: 'attr tag missing required path', text: '<write_file>orphan body</write_file>' },
|
|
192
|
+
{ name: 'invoke missing required param', text: '<minimax:tool_call><invoke name="write_file"></invoke></minimax:tool_call>' },
|
|
193
|
+
{ name: 'unknown tool name in wrapper', text: '<minimax:tool_call><invoke name="not_a_tool"><parameter name="x">1</parameter></invoke></minimax:tool_call>' },
|
|
194
|
+
{ name: 'empty tool_call JSON block', text: '<tool_call></tool_call>' },
|
|
195
|
+
{ name: 'invalid JSON in tool_call', text: '<tool_call>{not json at all}</tool_call>' },
|
|
196
|
+
{ name: 'plain prose, no tags', text: 'Just a normal explanation with no tool calls.' },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
for (const c of MALFORMED_CASES) {
|
|
200
|
+
test(`malformed/partial yields no calls: ${c.name}`, () => {
|
|
201
|
+
assert.deepStrictEqual(extractToolCalls(c.text), []);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test('a malformed tag does not prevent a valid sibling from parsing', () => {
|
|
206
|
+
const text = '<write_file path="ok.txt">good</write_file>\n<write_file>broken</write_file>';
|
|
207
|
+
assert.deepStrictEqual(extractToolCalls(text), [['write', 'ok.txt', 'good']]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// options.repairMalformedXml
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
test('repairMalformedXml option recovers stripped-bracket MiniMax output', () => {
|
|
215
|
+
const broken = [
|
|
216
|
+
'<minimax:tool_call>',
|
|
217
|
+
'invoke name="make_dir">',
|
|
218
|
+
'parameter name="path">build</parameter>',
|
|
219
|
+
'invoke>',
|
|
220
|
+
'</minimax:tool_call>',
|
|
221
|
+
].join('\n');
|
|
222
|
+
assert.deepStrictEqual(extractToolCalls(broken), [], 'without repair: nothing parses');
|
|
223
|
+
assert.deepStrictEqual(
|
|
224
|
+
extractToolCalls(broken, { repairMalformedXml: true }),
|
|
225
|
+
[['make_dir', 'build']],
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// mapInvokeToCall — direct unit coverage of the name→call mapping + guards.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
test('mapInvokeToCall: name is case-insensitive', () => {
|
|
234
|
+
assert.deepStrictEqual(mapInvokeToCall('WRITE_FILE', { path: 'a', content: 'b' }), ['write', 'a', 'b']);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('mapInvokeToCall: missing required param returns null', () => {
|
|
238
|
+
assert.strictEqual(mapInvokeToCall('write_file', { content: 'no path' }), null);
|
|
239
|
+
assert.strictEqual(mapInvokeToCall('move_file', { src: 'a' }), null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('mapInvokeToCall: defaults — list_dir/search_files fall back', () => {
|
|
243
|
+
assert.deepStrictEqual(mapInvokeToCall('list_dir', {}), ['list_dir', '.']);
|
|
244
|
+
assert.deepStrictEqual(mapInvokeToCall('search_files', {}), ['search_files', '*', '.']);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('mapInvokeToCall: exec/shell map to shell', () => {
|
|
248
|
+
assert.deepStrictEqual(mapInvokeToCall('exec', { command: 'ls' }), ['shell', 'ls']);
|
|
249
|
+
assert.deepStrictEqual(mapInvokeToCall('shell', { command: 'ls' }), ['shell', 'ls']);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('mapInvokeToCall: unknown tool returns null', () => {
|
|
253
|
+
assert.strictEqual(mapInvokeToCall('frobnicate', { x: 1 }), null);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Every name→call mapping, driven directly (the wrapper paths only reach a
|
|
257
|
+
// subset of these by name, so cover the rest here).
|
|
258
|
+
const MAP_CASES = [
|
|
259
|
+
['read_file', { path: 'a' }, ['read', 'a', null, null, false]],
|
|
260
|
+
['append_file', { path: 'a', content: 'c' }, ['append', 'a', 'c']],
|
|
261
|
+
['append_file', { path: 'a' }, ['append', 'a', '']],
|
|
262
|
+
['delete_file', { path: 'a' }, ['delete_file', 'a']],
|
|
263
|
+
['make_dir', { path: 'a' }, ['make_dir', 'a']],
|
|
264
|
+
['remove_dir', { path: 'a' }, ['remove_dir', 'a']],
|
|
265
|
+
['copy_file', { src: 'a', dst: 'b' }, ['copy_file', 'a', 'b']],
|
|
266
|
+
['file_stat', { path: 'a' }, ['file_stat', 'a']],
|
|
267
|
+
['search_in_file', { path: 'a', pattern: 'p' }, ['search_in_file', 'a', 'p']],
|
|
268
|
+
['replace_in_file', { path: 'a', search: 'o', replace: 'n' }, ['replace_in_file', 'a', 'o', 'n', '']],
|
|
269
|
+
['replace_in_file', { path: 'a', search: 'o', replace: 'n', flags: 'g' }, ['replace_in_file', 'a', 'o', 'n', 'g']],
|
|
270
|
+
['get_env', { name: 'X' }, ['get_env', 'X']],
|
|
271
|
+
['set_env', { name: 'X', value: 'v' }, ['set_env', 'X', 'v']],
|
|
272
|
+
['set_env', { name: 'X' }, ['set_env', 'X', '']],
|
|
273
|
+
['download', { url: 'http://x' }, ['download', 'http://x']],
|
|
274
|
+
['upload', { path: 'a', content: 'b64' }, ['upload', 'a', 'b64']],
|
|
275
|
+
['http_get', { url: 'http://x' }, ['http_get', 'http://x', {}]],
|
|
276
|
+
['ask_user', { question: 'q?' }, ['ask_user', 'q?']],
|
|
277
|
+
['store_memory', { key: 'k', value: 'v' }, ['store_memory', 'k', 'v']],
|
|
278
|
+
['recall_memory', { key: 'k' }, ['recall_memory', 'k']],
|
|
279
|
+
['list_memories', {}, ['list_memories']],
|
|
280
|
+
['system_info', {}, ['system_info']],
|
|
281
|
+
];
|
|
282
|
+
for (const [name, params, expected] of MAP_CASES) {
|
|
283
|
+
test(`mapInvokeToCall: ${name}(${JSON.stringify(params)})`, () => {
|
|
284
|
+
assert.deepStrictEqual(mapInvokeToCall(name, params), expected);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
test('mapInvokeToCall: more missing-required-param guards return null', () => {
|
|
289
|
+
assert.strictEqual(mapInvokeToCall('read_file', {}), null);
|
|
290
|
+
assert.strictEqual(mapInvokeToCall('copy_file', { src: 'a' }), null);
|
|
291
|
+
assert.strictEqual(mapInvokeToCall('search_in_file', { path: 'a' }), null);
|
|
292
|
+
assert.strictEqual(mapInvokeToCall('get_env', {}), null);
|
|
293
|
+
assert.strictEqual(mapInvokeToCall('ask_user', {}), null);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('mapInvokeToCall: edit_file coerces line to int', () => {
|
|
297
|
+
assert.deepStrictEqual(mapInvokeToCall('edit_file', { path: 'a', line: '7', content: 'x' }), ['edit_file', 'a', 7, 'x']);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// repairMinimaxMalformedXml — direct unit coverage.
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
test('repairMinimaxMalformedXml: only rewrites line-start tokens', () => {
|
|
305
|
+
const input = 'invoke name="x">\nparameter name="y">v</parameter>\ninvoke>';
|
|
306
|
+
const out = repairMinimaxMalformedXml(input);
|
|
307
|
+
assert.match(out, /<invoke name="x">/);
|
|
308
|
+
assert.match(out, /<parameter name="y">/);
|
|
309
|
+
assert.match(out, /<\/invoke>/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('repairMinimaxMalformedXml: non-string input is returned unchanged', () => {
|
|
313
|
+
assert.strictEqual(repairMinimaxMalformedXml(null), null);
|
|
314
|
+
assert.strictEqual(repairMinimaxMalformedXml(''), '');
|
|
315
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fix — http_get / download must turn ANY malformed URL (or unsupported scheme,
|
|
4
|
+
// empty/whitespace, non-string) into a clean tool error, never an uncaught
|
|
5
|
+
// throw out of the executor.
|
|
6
|
+
//
|
|
7
|
+
// Root cause: `new URL(...)` — and http.get/https.get's own internal parse —
|
|
8
|
+
// throws SYNCHRONOUSLY before the request starts, OUTSIDE the request-level
|
|
9
|
+
// `.on('error')` handlers that gracefully turn EHOSTUNREACH / DNS / timeout into
|
|
10
|
+
// tool errors. So a model-guessed bad URL (invented domain, non-ASCII host,
|
|
11
|
+
// stray chars) crashed the whole session. These tests pin: bad input → a clean
|
|
12
|
+
// `{ error, error_code }` result, no request made, nothing thrown — paired with
|
|
13
|
+
// a valid URL that still fetches end-to-end (the 5.0a paired-positive lesson).
|
|
14
|
+
//
|
|
15
|
+
// Home-based paths are redirected to a temp dir BEFORE any lib module loads so
|
|
16
|
+
// the secret-file/config guards resolve against the temp config path.
|
|
17
|
+
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-urlval-home-'));
|
|
23
|
+
const PREV_HOME = process.env.HOME;
|
|
24
|
+
const PREV_USERPROFILE = process.env.USERPROFILE;
|
|
25
|
+
process.env.HOME = TMP_HOME;
|
|
26
|
+
process.env.USERPROFILE = TMP_HOME;
|
|
27
|
+
|
|
28
|
+
const { test, before, after } = require('node:test');
|
|
29
|
+
const assert = require('node:assert');
|
|
30
|
+
const http = require('node:http');
|
|
31
|
+
|
|
32
|
+
const ui = require('../lib/ui');
|
|
33
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
34
|
+
const { createToolExecutor } = require('../lib/tools');
|
|
35
|
+
const { _validateFetchUrl } = require('../lib/tool_registry');
|
|
36
|
+
|
|
37
|
+
let CWD;
|
|
38
|
+
let PREV_CWD;
|
|
39
|
+
let server;
|
|
40
|
+
let baseUrl;
|
|
41
|
+
// Count every inbound request so we can prove a bad URL makes NO request.
|
|
42
|
+
let requestCount = 0;
|
|
43
|
+
|
|
44
|
+
function mkExec({ config = {}, pmOpts = {}, webChat } = {}) {
|
|
45
|
+
const pm = createPermissionManager(ui, pmOpts);
|
|
46
|
+
const getConfig = () => ({
|
|
47
|
+
max_file_size_kb: 512,
|
|
48
|
+
command_timeout_ms: 30000,
|
|
49
|
+
http_fetch_max_bytes: 262144,
|
|
50
|
+
download_max_bytes: 1048576,
|
|
51
|
+
// Default web config: extract only (no summarizer needed) for predictable
|
|
52
|
+
// assertions on the happy path.
|
|
53
|
+
web: { summarize: false, summary_model: '', max_content_tokens: 6000 },
|
|
54
|
+
...config,
|
|
55
|
+
});
|
|
56
|
+
return createToolExecutor(pm, ui, getConfig, { webChat });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fetchUrl = (exec, url, callOpts = {}) =>
|
|
60
|
+
exec.agentExecFile('http_get', url, callOpts, { signal: null });
|
|
61
|
+
|
|
62
|
+
before(async () => {
|
|
63
|
+
PREV_CWD = process.cwd();
|
|
64
|
+
CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-urlval-cwd-'));
|
|
65
|
+
process.chdir(CWD);
|
|
66
|
+
server = http.createServer((req, res) => {
|
|
67
|
+
requestCount += 1;
|
|
68
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
69
|
+
res.end('hello body');
|
|
70
|
+
});
|
|
71
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
72
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
after(async () => {
|
|
76
|
+
await new Promise((r) => server.close(r));
|
|
77
|
+
process.chdir(PREV_CWD);
|
|
78
|
+
if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
|
|
79
|
+
if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Pure helper — _validateFetchUrl
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
test('_validateFetchUrl: valid http(s) URLs normalize and pass', () => {
|
|
87
|
+
assert.strictEqual(_validateFetchUrl('http://example.com').url, 'http://example.com/');
|
|
88
|
+
assert.strictEqual(_validateFetchUrl('https://example.com/a?b=c').url, 'https://example.com/a?b=c');
|
|
89
|
+
assert.ok(!_validateFetchUrl('https://example.com').error);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('_validateFetchUrl: malformed / non-ASCII / stray-char URLs are errors', () => {
|
|
93
|
+
for (const bad of ['not a url', 'http://', 'ht!tp://x', '文字好意.com', 'http://exa mple.com']) {
|
|
94
|
+
const r = _validateFetchUrl(bad);
|
|
95
|
+
assert.ok(r.error, `expected error for ${JSON.stringify(bad)}`);
|
|
96
|
+
assert.strictEqual(r.error_code, 'ERR_INVALID_URL');
|
|
97
|
+
assert.ok(!r.url);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('_validateFetchUrl: non-http(s) schemes are rejected', () => {
|
|
102
|
+
for (const bad of ['file:///etc/passwd', 'ftp://x/y', 'javascript:alert(1)', 'data:text/plain,hi']) {
|
|
103
|
+
const r = _validateFetchUrl(bad);
|
|
104
|
+
assert.ok(r.error, `expected error for ${JSON.stringify(bad)}`);
|
|
105
|
+
assert.strictEqual(r.error_code, 'ERR_INVALID_PROTOCOL');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('_validateFetchUrl: empty / whitespace / non-string are errors', () => {
|
|
110
|
+
for (const bad of ['', ' ', null, undefined, 42, {}]) {
|
|
111
|
+
const r = _validateFetchUrl(bad);
|
|
112
|
+
assert.ok(r.error, `expected error for ${JSON.stringify(bad)}`);
|
|
113
|
+
assert.strictEqual(r.error_code, 'ERR_INVALID_URL');
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('_validateFetchUrl: a relative redirect Location resolves against its base', () => {
|
|
118
|
+
assert.strictEqual(_validateFetchUrl('/next', 'http://h/a/b').url, 'http://h/next');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// http_get executor — bad input never throws, makes no request
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test('http_get: invalid URL (non-ASCII / stray chars) → tool error, no throw, no request', async () => {
|
|
126
|
+
const exec = mkExec();
|
|
127
|
+
const before = requestCount;
|
|
128
|
+
let r;
|
|
129
|
+
await assert.doesNotReject(async () => { r = await fetchUrl(exec, 'http://文字 好意.com/x'); });
|
|
130
|
+
assert.ok(r.error, 'should return an error result');
|
|
131
|
+
assert.match(r.error, /invalid url/i);
|
|
132
|
+
assert.strictEqual(r.error_code, 'ERR_INVALID_URL');
|
|
133
|
+
assert.strictEqual(requestCount, before, 'no request should have been made');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('http_get: the exact log-style invalid input does not crash', async () => {
|
|
137
|
+
const exec = mkExec();
|
|
138
|
+
let r;
|
|
139
|
+
await assert.doesNotReject(async () => { r = await fetchUrl(exec, '文字好意.com'); });
|
|
140
|
+
assert.ok(r.error);
|
|
141
|
+
assert.match(r.error, /invalid url/i);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('http_get: non-http(s) scheme → clean tool error, no request', async () => {
|
|
145
|
+
const exec = mkExec();
|
|
146
|
+
for (const bad of ['file:///etc/passwd', 'ftp://host/x', 'javascript:alert(1)']) {
|
|
147
|
+
const before = requestCount;
|
|
148
|
+
const r = await fetchUrl(exec, bad);
|
|
149
|
+
assert.ok(r.error, `expected error for ${bad}`);
|
|
150
|
+
assert.match(r.error, /unsupported protocol|invalid url/i);
|
|
151
|
+
assert.strictEqual(requestCount, before, `no request for ${bad}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('http_get: empty / whitespace / non-string URL → clean tool error', async () => {
|
|
156
|
+
const exec = mkExec();
|
|
157
|
+
for (const bad of ['', ' ', null]) {
|
|
158
|
+
const r = await fetchUrl(exec, bad);
|
|
159
|
+
assert.ok(r.error, `expected error for ${JSON.stringify(bad)}`);
|
|
160
|
+
assert.strictEqual(r.error_code, 'ERR_INVALID_URL');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('http_get: error shape matches the network-failure shape ({ error, error_code })', async () => {
|
|
165
|
+
const exec = mkExec();
|
|
166
|
+
// A malformed URL (sync) and an unreachable host (async .on(error)) should
|
|
167
|
+
// both surface the same keys, so the agent handles them identically.
|
|
168
|
+
const bad = await fetchUrl(exec, 'http://');
|
|
169
|
+
// 127.0.0.1:1 — refused fast, exercising the async request-error path.
|
|
170
|
+
const unreachable = await fetchUrl(exec, 'http://127.0.0.1:1/');
|
|
171
|
+
for (const r of [bad, unreachable]) {
|
|
172
|
+
assert.ok('error' in r, 'has error');
|
|
173
|
+
assert.ok('error_code' in r, 'has error_code');
|
|
174
|
+
assert.strictEqual(typeof r.error, 'string');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Paired positive — a valid URL still fetches end-to-end
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
test('http_get: a VALID URL still works end-to-end (paired positive)', async () => {
|
|
183
|
+
const exec = mkExec();
|
|
184
|
+
const r = await fetchUrl(exec, `${baseUrl}/page`);
|
|
185
|
+
assert.ok(!r.error, `valid URL should not error: ${r.error}`);
|
|
186
|
+
assert.strictEqual(r.status_code, 200);
|
|
187
|
+
assert.match(r.body, /hello body/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// download executor — bad input never throws, makes no request, no file
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
test('download: invalid URL → tool error, no throw, no request, no file', async () => {
|
|
195
|
+
const exec = mkExec();
|
|
196
|
+
const before = requestCount;
|
|
197
|
+
let r;
|
|
198
|
+
await assert.doesNotReject(async () => {
|
|
199
|
+
r = await exec.agentExecFile('download', 'http://bad host/x', 'out.bin');
|
|
200
|
+
});
|
|
201
|
+
assert.ok(r.error, 'should return an error result');
|
|
202
|
+
assert.match(r.error, /invalid url/i);
|
|
203
|
+
assert.strictEqual(requestCount, before, 'no request should have been made');
|
|
204
|
+
assert.strictEqual(fs.existsSync(path.join(CWD, 'out.bin')), false, 'no file written');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('download: non-http(s) scheme → clean tool error', async () => {
|
|
208
|
+
const exec = mkExec();
|
|
209
|
+
const r = await exec.agentExecFile('download', 'file:///etc/passwd', 'p.bin');
|
|
210
|
+
assert.ok(r.error);
|
|
211
|
+
assert.match(r.error, /unsupported protocol|invalid url/i);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('download: a VALID URL still downloads (paired positive)', async () => {
|
|
215
|
+
const exec = mkExec();
|
|
216
|
+
const r = await exec.agentExecFile('download', `${baseUrl}/file.txt`, 'file.txt');
|
|
217
|
+
assert.strictEqual(r.status, 'ok');
|
|
218
|
+
assert.strictEqual(fs.readFileSync(path.join(CWD, 'file.txt'), 'utf8'), 'hello body');
|
|
219
|
+
});
|
|
@@ -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
|
+
};
|