@semalt-ai/code 1.8.4 → 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 +8 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1588 -27
- package/README.md +147 -3
- package/TECHNICAL_DEBT.md +66 -0
- package/examples/embed.js +74 -0
- package/index.js +259 -11
- package/lib/agent.js +935 -181
- package/lib/api.js +308 -55
- package/lib/args.js +96 -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 +346 -11
- package/lib/constants.js +372 -3
- package/lib/debug.js +106 -0
- 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 +158 -0
- package/lib/prompts.js +88 -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 +2558 -0
- package/lib/tool_specs.js +236 -9
- package/lib/tools.js +370 -944
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +101 -6
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/ui/writer.js +7 -9
- 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 -1288
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Integration tests for multimodal image input on the wire (Task 5.4). They
|
|
4
|
+
// drive the REAL api client / SDK against the mock-LLM harness and assert the
|
|
5
|
+
// PROVIDER-SPECIFIC content-part shape that actually leaves the client, the
|
|
6
|
+
// fail-loud vision check (image is NEVER silently dropped), and that the
|
|
7
|
+
// headless/SDK surface accepts images with the rest of the loop unaffected.
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { createApiClient } = require('../lib/api');
|
|
16
|
+
const { createAgent } = require('../lib/sdk');
|
|
17
|
+
const ui = require('../lib/ui');
|
|
18
|
+
const { normalizeConfig } = require('../lib/config');
|
|
19
|
+
const { startMockLLM } = require('./harness/mock-llm');
|
|
20
|
+
|
|
21
|
+
const PNG_BUF = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
|
22
|
+
const IMG = { media_type: 'image/png', data: PNG_BUF.toString('base64') };
|
|
23
|
+
|
|
24
|
+
function clientFor(mock, cfgOverride = {}) {
|
|
25
|
+
let config = normalizeConfig({ api_base: mock.base, api_key: 'k', default_model: 'gpt-4o', ...cfgOverride });
|
|
26
|
+
const getConfig = () => config;
|
|
27
|
+
const saveConfig = (c) => { config = normalizeConfig(c); };
|
|
28
|
+
return createApiClient({ getConfig, saveConfig, ui });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lastRequestMessages(mock) {
|
|
32
|
+
const req = mock.requests[mock.requests.length - 1];
|
|
33
|
+
return JSON.parse(req.body).messages;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── provider-specific content-part shape (constraint #1) ─────────────────────
|
|
37
|
+
|
|
38
|
+
test('OpenAI-style image_url is sent for an OpenAI-compatible vision model', async () => {
|
|
39
|
+
const mock = await startMockLLM();
|
|
40
|
+
mock.replyWith('It is a diagram.');
|
|
41
|
+
const client = clientFor(mock); // default model gpt-4o → vision via heuristic
|
|
42
|
+
try {
|
|
43
|
+
await client.chatStream(
|
|
44
|
+
[{ role: 'user', content: 'what is this', images: [IMG] }],
|
|
45
|
+
{ model: 'gpt-4o', silent: true },
|
|
46
|
+
);
|
|
47
|
+
const msgs = lastRequestMessages(mock);
|
|
48
|
+
const user = msgs.find((m) => m.role === 'user');
|
|
49
|
+
assert.ok(Array.isArray(user.content), 'content is a multimodal array');
|
|
50
|
+
const textPart = user.content.find((p) => p.type === 'text');
|
|
51
|
+
const imgPart = user.content.find((p) => p.type === 'image_url');
|
|
52
|
+
assert.strictEqual(textPart.text, 'what is this');
|
|
53
|
+
assert.ok(imgPart, 'image_url part present');
|
|
54
|
+
assert.strictEqual(imgPart.image_url.url, `data:image/png;base64,${IMG.data}`);
|
|
55
|
+
assert.strictEqual(user.images, undefined, 'internal images field not on the wire');
|
|
56
|
+
} finally {
|
|
57
|
+
await mock.close();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Anthropic-style image source block is sent for an anthropic-format profile', async () => {
|
|
62
|
+
const mock = await startMockLLM();
|
|
63
|
+
mock.replyWith('ack');
|
|
64
|
+
const client = clientFor(mock, {
|
|
65
|
+
default_model: 'claude-vision',
|
|
66
|
+
models: [{ api_base: mock.base, api_key: 'k', model: 'claude-vision', image_format: 'anthropic', vision: true }],
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await client.chatStream(
|
|
70
|
+
[{ role: 'user', content: 'describe', images: [IMG] }],
|
|
71
|
+
{ model: 'claude-vision', silent: true },
|
|
72
|
+
);
|
|
73
|
+
const msgs = lastRequestMessages(mock);
|
|
74
|
+
const user = msgs.find((m) => m.role === 'user');
|
|
75
|
+
const imgPart = user.content.find((p) => p.type === 'image');
|
|
76
|
+
assert.ok(imgPart, 'anthropic image part present');
|
|
77
|
+
assert.deepStrictEqual(imgPart.source, { type: 'base64', media_type: 'image/png', data: IMG.data });
|
|
78
|
+
assert.ok(!user.content.some((p) => p.type === 'image_url'), 'no OpenAI-style part');
|
|
79
|
+
} finally {
|
|
80
|
+
await mock.close();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── vision capability: fail loud, NEVER silently drop (constraint #2) ─────────
|
|
85
|
+
|
|
86
|
+
test('text-only model → clear error, image NOT sent (no silent drop)', async () => {
|
|
87
|
+
const mock = await startMockLLM();
|
|
88
|
+
// Deliberately queue NOTHING — a request would 500. We assert none is made.
|
|
89
|
+
const client = clientFor(mock, {
|
|
90
|
+
default_model: 'text-only',
|
|
91
|
+
models: [{ api_base: mock.base, api_key: 'k', model: 'text-only', vision: false }],
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
() => client.chatStream(
|
|
96
|
+
[{ role: 'user', content: 'see this', images: [IMG] }],
|
|
97
|
+
{ model: 'text-only', silent: true },
|
|
98
|
+
),
|
|
99
|
+
/not vision-capable/i,
|
|
100
|
+
);
|
|
101
|
+
assert.strictEqual(mock.requestCount(), 0, 'no request left the client — image not silently dropped');
|
|
102
|
+
} finally {
|
|
103
|
+
await mock.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('paired positive: a vision model accepts the same image', async () => {
|
|
108
|
+
const mock = await startMockLLM();
|
|
109
|
+
mock.replyWith('I see a cat.');
|
|
110
|
+
const client = clientFor(mock, {
|
|
111
|
+
default_model: 'vision-ok',
|
|
112
|
+
models: [{ api_base: mock.base, api_key: 'k', model: 'vision-ok', vision: true }],
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const res = await client.chatStream(
|
|
116
|
+
[{ role: 'user', content: 'see this', images: [IMG] }],
|
|
117
|
+
{ model: 'vision-ok', silent: true },
|
|
118
|
+
);
|
|
119
|
+
assert.match(res.content, /cat/);
|
|
120
|
+
assert.strictEqual(mock.requestCount(), 1);
|
|
121
|
+
} finally {
|
|
122
|
+
await mock.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── the rest of the loop is unaffected when images are present ───────────────
|
|
127
|
+
|
|
128
|
+
test('a plain text turn (no images) still sends string content', async () => {
|
|
129
|
+
const mock = await startMockLLM();
|
|
130
|
+
mock.replyWith('hi');
|
|
131
|
+
const client = clientFor(mock);
|
|
132
|
+
try {
|
|
133
|
+
await client.chatStream([{ role: 'user', content: 'hello' }], { model: 'gpt-4o', silent: true });
|
|
134
|
+
const msgs = lastRequestMessages(mock);
|
|
135
|
+
const user = msgs.find((m) => m.role === 'user');
|
|
136
|
+
assert.strictEqual(user.content, 'hello', 'string content unchanged without images');
|
|
137
|
+
} finally {
|
|
138
|
+
await mock.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── SDK / headless surface accepts images, envelope unaffected ───────────────
|
|
143
|
+
|
|
144
|
+
test('SDK run({ images }) reads a real file, attaches it, returns the envelope', async () => {
|
|
145
|
+
const prevKey = process.env.SEMALT_API_KEY;
|
|
146
|
+
process.env.SEMALT_API_KEY = 'test-key';
|
|
147
|
+
const prevCwd = process.cwd();
|
|
148
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'images-sdk-'));
|
|
149
|
+
process.chdir(tmpDir);
|
|
150
|
+
const imgPath = path.join(tmpDir, 'shot.png');
|
|
151
|
+
fs.writeFileSync(imgPath, PNG_BUF);
|
|
152
|
+
|
|
153
|
+
const mock = await startMockLLM();
|
|
154
|
+
mock.replyWith('A screenshot of a form.');
|
|
155
|
+
const agent = createAgent({
|
|
156
|
+
apiBase: mock.base,
|
|
157
|
+
apiKey: 'test-key',
|
|
158
|
+
model: 'gpt-4o',
|
|
159
|
+
sandbox: { mode: 'off' },
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const res = await agent.run('what is in this screenshot', { images: [imgPath] });
|
|
163
|
+
assert.match(res.result, /screenshot/i);
|
|
164
|
+
assert.ok(Array.isArray(res.toolCalls));
|
|
165
|
+
assert.strictEqual(res.stopReason, 'end_turn');
|
|
166
|
+
// The image actually rode the request as an OpenAI-style part.
|
|
167
|
+
const msgs = lastRequestMessages(mock);
|
|
168
|
+
const user = msgs.find((m) => Array.isArray(m.content) && m.content.some((p) => p.type === 'image_url'));
|
|
169
|
+
assert.ok(user, 'image_url part sent from a real file path');
|
|
170
|
+
} finally {
|
|
171
|
+
await agent.close();
|
|
172
|
+
await mock.close();
|
|
173
|
+
process.chdir(prevCwd);
|
|
174
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
|
|
175
|
+
else process.env.SEMALT_API_KEY = prevKey;
|
|
176
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('SDK run({ images }) refuses an out-of-CWD path via isPathSafe', async () => {
|
|
181
|
+
const prevKey = process.env.SEMALT_API_KEY;
|
|
182
|
+
process.env.SEMALT_API_KEY = 'test-key';
|
|
183
|
+
const prevCwd = process.cwd();
|
|
184
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'images-sdk-cwd-'));
|
|
185
|
+
// Write the image OUTSIDE the CWD we will chdir into.
|
|
186
|
+
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'images-outside-'));
|
|
187
|
+
const outsidePath = path.join(outsideDir, 'secret.png');
|
|
188
|
+
fs.writeFileSync(outsidePath, PNG_BUF);
|
|
189
|
+
process.chdir(tmpDir);
|
|
190
|
+
|
|
191
|
+
const mock = await startMockLLM();
|
|
192
|
+
const agent = createAgent({ apiBase: mock.base, apiKey: 'test-key', model: 'gpt-4o', sandbox: { mode: 'off' } });
|
|
193
|
+
try {
|
|
194
|
+
await assert.rejects(
|
|
195
|
+
() => agent.run('peek', { images: [outsidePath] }),
|
|
196
|
+
/outside allowed area/i,
|
|
197
|
+
);
|
|
198
|
+
assert.strictEqual(mock.requestCount(), 0, 'refused before any request');
|
|
199
|
+
} finally {
|
|
200
|
+
await agent.close();
|
|
201
|
+
await mock.close();
|
|
202
|
+
process.chdir(prevCwd);
|
|
203
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
|
|
204
|
+
else process.env.SEMALT_API_KEY = prevKey;
|
|
205
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
206
|
+
try { fs.rmSync(outsideDir, { recursive: true, force: true }); } catch {}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Unit tests for the multimodal image-input module (Task 5.4). Pure surface:
|
|
4
|
+
// media-type detection per format, the read path (size cap + isPathSafe + format
|
|
5
|
+
// guards), provider-specific content-part shaping (Anthropic vs OpenAI), the
|
|
6
|
+
// format-selection precedence, vision-capability resolution (fail-loud), and the
|
|
7
|
+
// message transform helpers.
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const images = require('../lib/images');
|
|
16
|
+
const { DEFAULT_IMAGE_MAX_BYTES } = require('../lib/constants');
|
|
17
|
+
|
|
18
|
+
// Minimal buffers with the right magic bytes for each format (≥12 bytes so the
|
|
19
|
+
// WebP offset check is in range).
|
|
20
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
|
21
|
+
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
22
|
+
const GIF = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0]);
|
|
23
|
+
const WEBP = Buffer.from([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]);
|
|
24
|
+
|
|
25
|
+
let tmp;
|
|
26
|
+
function tmpdir() {
|
|
27
|
+
if (!tmp) tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'images-test-'));
|
|
28
|
+
return tmp;
|
|
29
|
+
}
|
|
30
|
+
function writeTmp(name, buf) {
|
|
31
|
+
const p = path.join(tmpdir(), name);
|
|
32
|
+
fs.writeFileSync(p, buf);
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── media-type detection ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
test('detectMediaType: PNG/JPEG/WebP/GIF by magic bytes', () => {
|
|
39
|
+
assert.strictEqual(images.detectMediaType(PNG, 'x.bin'), 'image/png');
|
|
40
|
+
assert.strictEqual(images.detectMediaType(JPEG, 'x.bin'), 'image/jpeg');
|
|
41
|
+
assert.strictEqual(images.detectMediaType(GIF, 'x.bin'), 'image/gif');
|
|
42
|
+
assert.strictEqual(images.detectMediaType(WEBP, 'x.bin'), 'image/webp');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('detectMediaType: magic bytes win over a misleading extension', () => {
|
|
46
|
+
// A JPEG body named .png is classified as JPEG (authoritative header).
|
|
47
|
+
assert.strictEqual(images.detectMediaType(JPEG, 'photo.png'), 'image/jpeg');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('detectMediaType: extension fallback when header is inconclusive', () => {
|
|
51
|
+
const blank = Buffer.alloc(12, 0);
|
|
52
|
+
assert.strictEqual(images.detectMediaType(blank, 'a.jpeg'), 'image/jpeg');
|
|
53
|
+
assert.strictEqual(images.detectMediaType(blank, 'a.gif'), 'image/gif');
|
|
54
|
+
assert.strictEqual(images.detectMediaType(blank, 'a.txt'), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── readImage: encode + guards ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('readImage: reads, detects type, base64-encodes', () => {
|
|
60
|
+
const p = writeTmp('ok.png', PNG);
|
|
61
|
+
const img = images.readImage(p, { isPathSafe: () => true });
|
|
62
|
+
assert.strictEqual(img.media_type, 'image/png');
|
|
63
|
+
assert.strictEqual(img.data, PNG.toString('base64'));
|
|
64
|
+
assert.strictEqual(img.bytes, PNG.length);
|
|
65
|
+
assert.strictEqual(img.path, p);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('readImage: out-of-CWD path refused by isPathSafe (clear error)', () => {
|
|
69
|
+
const p = writeTmp('blocked.png', PNG);
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => images.readImage(p, { isPathSafe: () => false }),
|
|
72
|
+
/outside allowed area/i,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('readImage: oversize → clear error, not an opaque endpoint failure', () => {
|
|
77
|
+
const p = writeTmp('big.png', PNG);
|
|
78
|
+
assert.throws(
|
|
79
|
+
() => images.readImage(p, { isPathSafe: () => true, maxBytes: 4 }),
|
|
80
|
+
/too large/i,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('readImage: unsupported format → clear error', () => {
|
|
85
|
+
const p = writeTmp('note.txt', Buffer.from('hello world, not an image'));
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => images.readImage(p, { isPathSafe: () => true }),
|
|
88
|
+
/Unsupported image format/i,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('readImage: missing file → clear error', () => {
|
|
93
|
+
assert.throws(
|
|
94
|
+
() => images.readImage(path.join(tmpdir(), 'nope.png'), { isPathSafe: () => true }),
|
|
95
|
+
/not found or unreadable/i,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('readImage: default size cap is the documented constant', () => {
|
|
100
|
+
// A 1-byte over the default cap would error; under it is fine. We only assert
|
|
101
|
+
// the default is wired (no maxBytes passed uses DEFAULT_IMAGE_MAX_BYTES).
|
|
102
|
+
const p = writeTmp('small.png', PNG);
|
|
103
|
+
assert.doesNotThrow(() => images.readImage(p, { isPathSafe: () => true }));
|
|
104
|
+
assert.ok(DEFAULT_IMAGE_MAX_BYTES > 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('readImages: order preserved; throws on first bad input', () => {
|
|
108
|
+
const a = writeTmp('a.png', PNG);
|
|
109
|
+
const b = writeTmp('b.jpeg', JPEG);
|
|
110
|
+
const out = images.readImages([a, b], { isPathSafe: () => true });
|
|
111
|
+
assert.strictEqual(out.length, 2);
|
|
112
|
+
assert.strictEqual(out[0].media_type, 'image/png');
|
|
113
|
+
assert.strictEqual(out[1].media_type, 'image/jpeg');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── provider-specific content-part shaping (constraint #1) ───────────────────
|
|
117
|
+
|
|
118
|
+
test('buildImagePart: OpenAI-style image_url data URL', () => {
|
|
119
|
+
const part = images.buildImagePart({ media_type: 'image/png', data: 'AAAA' }, 'openai');
|
|
120
|
+
assert.deepStrictEqual(part, {
|
|
121
|
+
type: 'image_url',
|
|
122
|
+
image_url: { url: 'data:image/png;base64,AAAA' },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('buildImagePart: Anthropic-style base64 source block', () => {
|
|
127
|
+
const part = images.buildImagePart({ media_type: 'image/jpeg', data: 'BBBB' }, 'anthropic');
|
|
128
|
+
assert.deepStrictEqual(part, {
|
|
129
|
+
type: 'image',
|
|
130
|
+
source: { type: 'base64', media_type: 'image/jpeg', data: 'BBBB' },
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('buildMultimodalContent: text part first, then image parts', () => {
|
|
135
|
+
const parts = images.buildMultimodalContent('hello', [{ media_type: 'image/png', data: 'AAAA' }], 'openai');
|
|
136
|
+
assert.strictEqual(parts.length, 2);
|
|
137
|
+
assert.deepStrictEqual(parts[0], { type: 'text', text: 'hello' });
|
|
138
|
+
assert.strictEqual(parts[1].type, 'image_url');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('buildMultimodalContent: empty text → image-only content', () => {
|
|
142
|
+
const parts = images.buildMultimodalContent('', [{ media_type: 'image/png', data: 'AAAA' }], 'anthropic');
|
|
143
|
+
assert.strictEqual(parts.length, 1);
|
|
144
|
+
assert.strictEqual(parts[0].type, 'image');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── provider-format selection precedence ─────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
test('selectImageFormat: profile.image_format wins', () => {
|
|
150
|
+
const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', image_format: 'anthropic' }] };
|
|
151
|
+
assert.strictEqual(images.selectImageFormat(config, 'm'), 'anthropic');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('selectImageFormat: top-level config.image_format next', () => {
|
|
155
|
+
assert.strictEqual(images.selectImageFormat({ image_format: 'anthropic' }, 'm'), 'anthropic');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('selectImageFormat: heuristic — anthropic api_base → anthropic, else openai default', () => {
|
|
159
|
+
assert.strictEqual(images.selectImageFormat({ api_base: 'https://api.anthropic.com' }, 'claude-x'), 'anthropic');
|
|
160
|
+
assert.strictEqual(images.selectImageFormat({ api_base: 'http://127.0.0.1:8800' }, 'gpt-4o'), 'openai');
|
|
161
|
+
assert.strictEqual(images.selectImageFormat({}, 'whatever'), 'openai');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── vision capability (constraint #2: fail loud) ─────────────────────────────
|
|
165
|
+
|
|
166
|
+
test('resolveVisionCapability: profile vision:false → false (fail-loud signal)', () => {
|
|
167
|
+
const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', vision: false }] };
|
|
168
|
+
assert.strictEqual(images.resolveVisionCapability(config, 'm'), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('resolveVisionCapability: profile vision:true → true', () => {
|
|
172
|
+
const config = { api_base: 'http://x', models: [{ api_base: 'http://x', model: 'm', vision: true }] };
|
|
173
|
+
assert.strictEqual(images.resolveVisionCapability(config, 'm'), true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('resolveVisionCapability: known text-only family → false', () => {
|
|
177
|
+
assert.strictEqual(images.resolveVisionCapability({}, 'text-embedding-3-large'), false);
|
|
178
|
+
assert.strictEqual(images.resolveVisionCapability({}, 'whisper-1'), false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('resolveVisionCapability: known vision family → true', () => {
|
|
182
|
+
assert.strictEqual(images.resolveVisionCapability({}, 'gpt-4o'), true);
|
|
183
|
+
assert.strictEqual(images.resolveVisionCapability({}, 'claude-sonnet-4-6'), true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('resolveVisionCapability: unknown → null (proceed, endpoint decides)', () => {
|
|
187
|
+
assert.strictEqual(images.resolveVisionCapability({}, 'some-random-model'), null);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── message transform helpers ────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
test('buildProviderMessages: only image-bearing turns become content arrays', () => {
|
|
193
|
+
const msgs = [
|
|
194
|
+
{ role: 'system', content: 'sys' },
|
|
195
|
+
{ role: 'user', content: 'see this', images: [{ media_type: 'image/png', data: 'AAAA' }] },
|
|
196
|
+
{ role: 'assistant', content: 'ok' },
|
|
197
|
+
];
|
|
198
|
+
const out = images.buildProviderMessages(msgs, 'openai');
|
|
199
|
+
assert.strictEqual(out[0].content, 'sys');
|
|
200
|
+
assert.ok(Array.isArray(out[1].content));
|
|
201
|
+
assert.strictEqual(out[1].images, undefined, 'internal images field stripped');
|
|
202
|
+
assert.strictEqual(out[2].content, 'ok');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('buildProviderMessages: strips a stray empty images field', () => {
|
|
206
|
+
const out = images.buildProviderMessages([{ role: 'user', content: 'hi', images: [] }], 'openai');
|
|
207
|
+
assert.strictEqual(out[0].content, 'hi');
|
|
208
|
+
assert.ok(!('images' in out[0]));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('attachImagesToLastUser: attaches to the most recent user message', () => {
|
|
212
|
+
const msgs = [
|
|
213
|
+
{ role: 'user', content: 'first' },
|
|
214
|
+
{ role: 'assistant', content: 'a' },
|
|
215
|
+
{ role: 'user', content: 'second' },
|
|
216
|
+
];
|
|
217
|
+
images.attachImagesToLastUser(msgs, [{ media_type: 'image/png', data: 'AAAA' }]);
|
|
218
|
+
assert.strictEqual(msgs[0].images, undefined);
|
|
219
|
+
assert.strictEqual(msgs[2].images.length, 1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('resolveImageInputs: accepts paths and pre-encoded records', () => {
|
|
223
|
+
const p = writeTmp('mix.png', PNG);
|
|
224
|
+
const out = images.resolveImageInputs(
|
|
225
|
+
[p, { media_type: 'image/jpeg', data: 'BBBB' }],
|
|
226
|
+
{ isPathSafe: () => true },
|
|
227
|
+
);
|
|
228
|
+
assert.strictEqual(out.length, 2);
|
|
229
|
+
assert.strictEqual(out[0].media_type, 'image/png');
|
|
230
|
+
assert.strictEqual(out[1].data, 'BBBB');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('resolveImageInputs: rejects an invalid inline media type', () => {
|
|
234
|
+
assert.throws(
|
|
235
|
+
() => images.resolveImageInputs([{ media_type: 'application/pdf', data: 'x' }], {}),
|
|
236
|
+
/Unsupported image media type/i,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Iteration-cap tests (Pre-Task 4.0a). The primary agent loop must stop at an
|
|
4
|
+
// explicit default (50), be overridable via --max-iterations / config, support
|
|
5
|
+
// an explicit "unbounded" choice, terminate GRACEFULLY at the cap (clear
|
|
6
|
+
// message + stopReason), and surface that stop reason in headless json output.
|
|
7
|
+
|
|
8
|
+
const { test, before, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const ui = require('../lib/ui');
|
|
12
|
+
const { createApiClient } = require('../lib/api');
|
|
13
|
+
const { createToolExecutor, extractToolCalls } = require('../lib/tools');
|
|
14
|
+
const { createPermissionManager } = require('../lib/permissions');
|
|
15
|
+
const { createAgentRunner } = require('../lib/agent');
|
|
16
|
+
const { normalizeConfig, flagsConfigLayer, resolveMaxIterations } = require('../lib/config');
|
|
17
|
+
const { DEFAULT_MAX_ITERATIONS } = require('../lib/constants');
|
|
18
|
+
const { runHeadless } = require('../lib/headless');
|
|
19
|
+
const { startMockLLM } = require('./harness/mock-llm');
|
|
20
|
+
|
|
21
|
+
let prevKey;
|
|
22
|
+
before(() => { prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key'; });
|
|
23
|
+
after(() => {
|
|
24
|
+
if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
|
|
25
|
+
else process.env.SEMALT_API_KEY = prevKey;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function buildRunner(base) {
|
|
29
|
+
const config = {
|
|
30
|
+
api_base: base, api_key: 'test-key', default_model: 'test-model',
|
|
31
|
+
temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
|
|
32
|
+
};
|
|
33
|
+
const getConfig = () => config;
|
|
34
|
+
const saveConfig = (c) => { Object.assign(config, c); };
|
|
35
|
+
const api = createApiClient({ getConfig, saveConfig, ui });
|
|
36
|
+
const pm = createPermissionManager(ui, { skipPermissions: true });
|
|
37
|
+
pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
|
|
38
|
+
const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
|
|
39
|
+
const runner = createAgentRunner({
|
|
40
|
+
chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
|
|
41
|
+
describePermission, permissionManager: pm, ui, getConfig,
|
|
42
|
+
});
|
|
43
|
+
return { runner, config };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collector() {
|
|
47
|
+
const ev = { errors: [], tools: [] };
|
|
48
|
+
const cb = {
|
|
49
|
+
onToken: () => {},
|
|
50
|
+
onToolStart: () => {},
|
|
51
|
+
onToolEnd: (tag) => ev.tools.push(tag),
|
|
52
|
+
onError: (e) => ev.errors.push(e),
|
|
53
|
+
onAssistantMessage: () => {},
|
|
54
|
+
};
|
|
55
|
+
return { ev, cb };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 1. Reaching the cap terminates gracefully with a clear message + stopReason
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test('reaching the iteration cap stops gracefully with a clear message and stopReason', async () => {
|
|
63
|
+
const mock = await startMockLLM();
|
|
64
|
+
mock.replyWith('<exec>echo a</exec>');
|
|
65
|
+
mock.replyWith('<exec>echo b</exec>');
|
|
66
|
+
mock.replyWith('<exec>echo c</exec>'); // would be a 3rd turn — must NOT be reached
|
|
67
|
+
try {
|
|
68
|
+
const { runner } = buildRunner(mock.base);
|
|
69
|
+
const { ev, cb } = collector();
|
|
70
|
+
const messages = [{ role: 'user', content: 'loop forever' }];
|
|
71
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 2, null, { callbacks: cb });
|
|
72
|
+
|
|
73
|
+
assert.strictEqual(res.stopReason, 'max_iterations', 'stopReason reports the cap');
|
|
74
|
+
assert.strictEqual(res.metrics.turns.length, 2, 'stopped at the cap');
|
|
75
|
+
assert.strictEqual(mock.requestCount(), 2, 'no third request made');
|
|
76
|
+
|
|
77
|
+
const warn = ev.errors.find((e) => e && /max(imum)?/i.test(e.message) && /iteration/i.test(e.message));
|
|
78
|
+
assert.ok(warn, 'a graceful cap message was surfaced');
|
|
79
|
+
assert.ok(warn.isWarning, 'cap message is a warning, not a hard error');
|
|
80
|
+
assert.match(warn.message, /2/, 'mentions the limit that was hit');
|
|
81
|
+
assert.match(warn.message, /--max-iterations/, 'tells the user how to raise it');
|
|
82
|
+
} finally {
|
|
83
|
+
await mock.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// 2. --max-iterations / config overrides the default
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
test('an explicit cap overrides the default', async () => {
|
|
92
|
+
const mock = await startMockLLM();
|
|
93
|
+
for (let i = 0; i < 5; i++) mock.replyWith(`<exec>echo step${i}</exec>`);
|
|
94
|
+
try {
|
|
95
|
+
const { runner } = buildRunner(mock.base);
|
|
96
|
+
const { cb } = collector();
|
|
97
|
+
const messages = [{ role: 'user', content: 'loop' }];
|
|
98
|
+
const res = await runner.runAgentLoop(messages, 'test-model', 3, null, { callbacks: cb });
|
|
99
|
+
|
|
100
|
+
assert.strictEqual(res.stopReason, 'max_iterations');
|
|
101
|
+
assert.strictEqual(res.metrics.turns.length, 3, 'honored the explicit cap of 3');
|
|
102
|
+
assert.strictEqual(mock.requestCount(), 3);
|
|
103
|
+
} finally {
|
|
104
|
+
await mock.close();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// 3. The unbounded option does not cap a naturally-terminating loop
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
test('unbounded (Infinity) runs until the model stops on its own', async () => {
|
|
113
|
+
const mock = await startMockLLM();
|
|
114
|
+
mock.replyWith('<exec>echo one</exec>');
|
|
115
|
+
mock.replyWith('<exec>echo two</exec>');
|
|
116
|
+
mock.replyWith('All done.');
|
|
117
|
+
try {
|
|
118
|
+
const { runner } = buildRunner(mock.base);
|
|
119
|
+
const { cb } = collector();
|
|
120
|
+
const messages = [{ role: 'user', content: 'go' }];
|
|
121
|
+
const res = await runner.runAgentLoop(messages, 'test-model', Infinity, null, { callbacks: cb });
|
|
122
|
+
|
|
123
|
+
assert.notStrictEqual(res.stopReason, 'max_iterations', 'not stopped by a cap');
|
|
124
|
+
assert.strictEqual(res.stopReason, 'end_turn', 'ended on the final reply');
|
|
125
|
+
assert.strictEqual(mock.pending(), 0, 'ran to natural completion');
|
|
126
|
+
assert.ok(messages.some((m) => m.role === 'assistant' && m.content === 'All done.'));
|
|
127
|
+
} finally {
|
|
128
|
+
await mock.close();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 4. Config / flag resolution
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
test('config default max_iterations is 50', () => {
|
|
137
|
+
assert.strictEqual(DEFAULT_MAX_ITERATIONS, 50);
|
|
138
|
+
assert.strictEqual(normalizeConfig({}).max_iterations, 50);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('normalizeConfig accepts a positive override and falls back on garbage', () => {
|
|
142
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 7 }).max_iterations, 7);
|
|
143
|
+
assert.strictEqual(normalizeConfig({ max_iterations: -3 }).max_iterations, 50);
|
|
144
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 'banana' }).max_iterations, 50);
|
|
145
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 4.5 }).max_iterations, 50);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('0 and "unlimited" normalize to the unlimited sentinel (0)', () => {
|
|
149
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 0 }).max_iterations, 0);
|
|
150
|
+
assert.strictEqual(normalizeConfig({ max_iterations: 'unlimited' }).max_iterations, 0);
|
|
151
|
+
assert.strictEqual(normalizeConfig({ max_iterations: '0' }).max_iterations, 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('--max-iterations flows through the flags config layer', () => {
|
|
155
|
+
assert.strictEqual(normalizeConfig(flagsConfigLayer(['--max-iterations', '12'])).max_iterations, 12);
|
|
156
|
+
assert.strictEqual(normalizeConfig(flagsConfigLayer(['--max-iterations', 'unlimited'])).max_iterations, 0);
|
|
157
|
+
assert.strictEqual(normalizeConfig(flagsConfigLayer(['--max-iterations', '0'])).max_iterations, 0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('resolveMaxIterations maps the unlimited sentinel to Infinity', () => {
|
|
161
|
+
assert.strictEqual(resolveMaxIterations(50), 50);
|
|
162
|
+
assert.strictEqual(resolveMaxIterations(7), 7);
|
|
163
|
+
assert.strictEqual(resolveMaxIterations(0), Infinity);
|
|
164
|
+
assert.strictEqual(resolveMaxIterations('unlimited'), Infinity);
|
|
165
|
+
assert.strictEqual(resolveMaxIterations(undefined), DEFAULT_MAX_ITERATIONS);
|
|
166
|
+
assert.strictEqual(resolveMaxIterations('garbage'), DEFAULT_MAX_ITERATIONS);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// 5. Headless json surfaces the stop reason
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
test('headless json output reports stopReason when the cap is hit', async () => {
|
|
174
|
+
const mock = await startMockLLM();
|
|
175
|
+
mock.replyWith('<exec>echo a</exec>');
|
|
176
|
+
mock.replyWith('<exec>echo b</exec>');
|
|
177
|
+
mock.replyWith('<exec>echo c</exec>');
|
|
178
|
+
try {
|
|
179
|
+
const { runner } = buildRunner(mock.base);
|
|
180
|
+
let out = '';
|
|
181
|
+
await runHeadless({
|
|
182
|
+
runAgentLoop: runner.runAgentLoop,
|
|
183
|
+
messages: [{ role: 'user', content: 'loop' }],
|
|
184
|
+
model: 'test-model',
|
|
185
|
+
maxIterations: 2,
|
|
186
|
+
mode: 'json',
|
|
187
|
+
write: (s) => { out += s; },
|
|
188
|
+
});
|
|
189
|
+
const obj = JSON.parse(out.trim().split('\n').pop());
|
|
190
|
+
assert.strictEqual(obj.stopReason, 'max_iterations', 'json envelope carries the stop reason');
|
|
191
|
+
} finally {
|
|
192
|
+
await mock.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('headless json reports end_turn on natural completion', async () => {
|
|
197
|
+
const mock = await startMockLLM();
|
|
198
|
+
mock.replyWith('<exec>echo a</exec>');
|
|
199
|
+
mock.replyWith('Done.');
|
|
200
|
+
try {
|
|
201
|
+
const { runner } = buildRunner(mock.base);
|
|
202
|
+
let out = '';
|
|
203
|
+
await runHeadless({
|
|
204
|
+
runAgentLoop: runner.runAgentLoop,
|
|
205
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
206
|
+
model: 'test-model',
|
|
207
|
+
maxIterations: 10,
|
|
208
|
+
mode: 'json',
|
|
209
|
+
write: (s) => { out += s; },
|
|
210
|
+
});
|
|
211
|
+
const obj = JSON.parse(out.trim().split('\n').pop());
|
|
212
|
+
assert.strictEqual(obj.stopReason, 'end_turn');
|
|
213
|
+
} finally {
|
|
214
|
+
await mock.close();
|
|
215
|
+
}
|
|
216
|
+
});
|