@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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ // Integration tests for self-verification (Task 4.2) driving the REAL
4
+ // runAgentLoop against the mock-LLM harness, with the REAL createVerifyRunner
5
+ // reading config.verify (so spawnSync actually runs the verify command). Verify
6
+ // commands use `node -e …` so they are portable across the CI matrix.
7
+
8
+ const { test, before, after } = require('node:test');
9
+ const assert = require('node:assert');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const ui = require('../lib/ui');
15
+ const { createApiClient } = require('../lib/api');
16
+ const { createToolExecutor, extractToolCalls } = require('../lib/tools');
17
+ const { createPermissionManager } = require('../lib/permissions');
18
+ const { createAgentRunner } = require('../lib/agent');
19
+ const { runHeadless } = require('../lib/headless');
20
+ const { startMockLLM } = require('./harness/mock-llm');
21
+
22
+ let prevKey;
23
+ before(() => { prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key'; });
24
+ after(() => {
25
+ if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
26
+ else process.env.SEMALT_API_KEY = prevKey;
27
+ });
28
+
29
+ const NODE = JSON.stringify(process.execPath);
30
+
31
+ // buildRunner mirrors hooks-agent.test.js, but threads `verify` into config so
32
+ // the real verify runner (built inside createAgentRunner from getConfig) sees it.
33
+ function buildRunner(base, verify) {
34
+ const config = {
35
+ api_base: base, api_key: 'test-key', default_model: 'test-model',
36
+ temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
37
+ verify: verify || {},
38
+ // This suite tests verify ORCHESTRATION, not the OS sandbox (covered by
39
+ // hooks-verify-sandbox.test.js). Disable the sandbox so the verify commands
40
+ // run deterministically across the CI matrix regardless of bwrap/Seatbelt.
41
+ sandbox: { mode: 'off' },
42
+ };
43
+ const getConfig = () => config;
44
+ const saveConfig = (c) => Object.assign(config, c);
45
+ const api = createApiClient({ getConfig, saveConfig, ui });
46
+ const pm = createPermissionManager(ui, { skipPermissions: true });
47
+ pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
48
+ const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
49
+ const runner = createAgentRunner({
50
+ chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
51
+ describePermission, permissionManager: pm, ui, getConfig,
52
+ });
53
+ return { runner, config };
54
+ }
55
+
56
+ function collector() {
57
+ const ev = { errors: [], assistants: [] };
58
+ const cb = {
59
+ onError: (e) => ev.errors.push(e),
60
+ onAssistantMessage: (m) => ev.assistants.push(m),
61
+ };
62
+ return { ev, cb };
63
+ }
64
+
65
+ function lastFedVerify(messages) {
66
+ return [...messages].reverse().find((m) => m.role === 'user' && /\[verify/.test(m.content || ''));
67
+ }
68
+
69
+ function tmpdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-verify-')); }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // 1. Advisory: result fed into context, turn ends regardless of pass/fail
73
+ // ---------------------------------------------------------------------------
74
+
75
+ test('advisory verify FAILS: the result is fed into context but the turn still ends', async () => {
76
+ const verify = { mode: 'advisory', command: `${NODE} -e "process.stdout.write('ADVISORY_FAIL_OUT');process.exit(1)"` };
77
+ const mock = await startMockLLM();
78
+ mock.replyWith('Done.');
79
+ try {
80
+ const { runner } = buildRunner(mock.base, verify);
81
+ const { cb } = collector();
82
+ const messages = [{ role: 'user', content: 'do the task' }];
83
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
84
+
85
+ assert.strictEqual(res.verifyStatus, 'failed', 'a failing advisory verify reports failed');
86
+ assert.strictEqual(res.stopReason, 'end_turn', 'advisory NEVER blocks — turn ends normally');
87
+ assert.strictEqual(res.metrics.turns.length, 1, 'no re-entry into the loop in advisory mode');
88
+ const fed = lastFedVerify(messages);
89
+ assert.ok(fed, 'the verify result is fed into context');
90
+ assert.match(fed.content, /ADVISORY_FAIL_OUT/, 'the command output is present');
91
+ assert.match(fed.content, /UNTRUSTED_EXTERNAL_CONTENT/, 'verify output is fenced as untrusted');
92
+ } finally {
93
+ await mock.close();
94
+ }
95
+ });
96
+
97
+ test('advisory verify PASSES: the result is fed into context and the turn ends', async () => {
98
+ const verify = { mode: 'advisory', command: `${NODE} -e "process.exit(0)"` };
99
+ const mock = await startMockLLM();
100
+ mock.replyWith('Done.');
101
+ try {
102
+ const { runner } = buildRunner(mock.base, verify);
103
+ const { cb } = collector();
104
+ const messages = [{ role: 'user', content: 'do the task' }];
105
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
106
+
107
+ assert.strictEqual(res.verifyStatus, 'passed');
108
+ assert.strictEqual(res.stopReason, 'end_turn');
109
+ const fed = lastFedVerify(messages);
110
+ assert.ok(fed, 'a passing advisory verify is still fed into context as information');
111
+ assert.match(fed.content, /PASSED/);
112
+ } finally {
113
+ await mock.close();
114
+ }
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 2. Enforcing pass: verify passes, the turn ends
119
+ // ---------------------------------------------------------------------------
120
+
121
+ test('enforcing verify PASSES on the first try: the turn ends immediately', async () => {
122
+ const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(0)"` };
123
+ const mock = await startMockLLM();
124
+ mock.replyWith('All done.');
125
+ try {
126
+ const { runner } = buildRunner(mock.base, verify);
127
+ const { cb } = collector();
128
+ const messages = [{ role: 'user', content: 'do it' }];
129
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
130
+
131
+ assert.strictEqual(res.verifyStatus, 'passed');
132
+ assert.strictEqual(res.stopReason, 'end_turn');
133
+ assert.strictEqual(res.metrics.turns.length, 1, 'passing verify means no re-entry');
134
+ } finally {
135
+ await mock.close();
136
+ }
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 3. Enforcing fail-then-pass: failure re-enters the loop, second attempt passes
141
+ // ---------------------------------------------------------------------------
142
+
143
+ test('enforcing verify FAILS then PASSES: the agent is returned to the loop and finishes once verified', async () => {
144
+ const dir = tmpdir();
145
+ const marker = path.join(dir, 'mark');
146
+ const prev = process.env.SEMALT_VERIFY_MARKER;
147
+ process.env.SEMALT_VERIFY_MARKER = marker;
148
+ // Fail when the marker is absent (creating it), pass once it exists → exactly
149
+ // fail-then-pass across two verify runs.
150
+ const verify = {
151
+ mode: 'enforcing',
152
+ command: `${NODE} -e "const fs=require('fs');const f=process.env.SEMALT_VERIFY_MARKER;if(fs.existsSync(f)){process.exit(0)}else{fs.writeFileSync(f,'x');process.exit(1)}"`,
153
+ };
154
+ const mock = await startMockLLM();
155
+ mock.replyWith('Done (attempt 1).');
156
+ mock.replyWith('Fixed it (attempt 2).');
157
+ try {
158
+ const { runner } = buildRunner(mock.base, verify);
159
+ const { cb } = collector();
160
+ const messages = [{ role: 'user', content: 'do it' }];
161
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
162
+
163
+ assert.strictEqual(res.verifyStatus, 'passed', 'final verify passed');
164
+ assert.strictEqual(res.stopReason, 'end_turn');
165
+ assert.strictEqual(res.metrics.turns.length, 2, 'the failing verify re-entered the loop once');
166
+ // The first (failing) verify pushed a corrective, fenced message into context.
167
+ const reentry = messages.find((m) => m.role === 'user' && /NOT done/.test(m.content || ''));
168
+ assert.ok(reentry, 'a corrective re-entry message was injected on failure');
169
+ assert.match(reentry.content, /UNTRUSTED_EXTERNAL_CONTENT/, 'the failing result is fenced as untrusted');
170
+ assert.ok(messages.some((m) => m.role === 'assistant' && /attempt 2/.test(m.content)));
171
+ } finally {
172
+ await mock.close();
173
+ if (prev === undefined) delete process.env.SEMALT_VERIFY_MARKER;
174
+ else process.env.SEMALT_VERIFY_MARKER = prev;
175
+ fs.rmSync(dir, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // 4. Enforcing exhausts: N failures terminate with verify_failed, NOT the iteration cap
181
+ // ---------------------------------------------------------------------------
182
+
183
+ test('enforcing verify that never passes terminates with stopReason verify_failed after max_attempts', async () => {
184
+ const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(1)"`, max_attempts: 2 };
185
+ const mock = await startMockLLM();
186
+ // Queue more replies than the verify-attempt limit to prove we stop on the
187
+ // attempt limit, not by exhausting the (much larger) iteration cap.
188
+ mock.replyWith('Try 1.');
189
+ mock.replyWith('Try 2.');
190
+ mock.replyWith('Try 3.');
191
+ try {
192
+ const { runner } = buildRunner(mock.base, verify);
193
+ const { cb } = collector();
194
+ const messages = [{ role: 'user', content: 'do it' }];
195
+ const res = await runner.runAgentLoop(messages, 'test-model', 50, null, { callbacks: cb });
196
+
197
+ assert.strictEqual(res.stopReason, 'verify_failed', 'precise bound, not max_iterations');
198
+ assert.strictEqual(res.verifyStatus, 'failed');
199
+ assert.strictEqual(res.metrics.turns.length, 2, 'stopped after exactly max_attempts (2) failed verifies');
200
+ assert.ok(mock.pending() >= 1, 'the iteration cap was nowhere near — extra replies left unused');
201
+ } finally {
202
+ await mock.close();
203
+ }
204
+ });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // 5. Timeout treated as a failed verify, no hang
208
+ // ---------------------------------------------------------------------------
209
+
210
+ test('a hung verify command times out and is treated as a failed verification (no hang)', async () => {
211
+ const verify = { mode: 'advisory', command: `${NODE} -e "setTimeout(function(){}, 10000)"`, timeout_ms: 300 };
212
+ const mock = await startMockLLM();
213
+ mock.replyWith('Done.');
214
+ try {
215
+ const { runner } = buildRunner(mock.base, verify);
216
+ const { cb } = collector();
217
+ const messages = [{ role: 'user', content: 'do it' }];
218
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
219
+
220
+ assert.strictEqual(res.verifyStatus, 'failed', 'timeout is a failed verify');
221
+ assert.strictEqual(res.stopReason, 'end_turn', 'advisory still ends the turn');
222
+ const fed = lastFedVerify(messages);
223
+ assert.match(fed.content, /timed out/i);
224
+ } finally {
225
+ await mock.close();
226
+ }
227
+ });
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // 6. Deny-listed verify command is refused
231
+ // ---------------------------------------------------------------------------
232
+
233
+ test('a deny-listed verify command is refused (never run) and reported as a failed verify', async () => {
234
+ const verify = { mode: 'advisory', command: 'rm -rf /' };
235
+ const mock = await startMockLLM();
236
+ mock.replyWith('Done.');
237
+ try {
238
+ const { runner } = buildRunner(mock.base, verify);
239
+ const { cb } = collector();
240
+ const messages = [{ role: 'user', content: 'do it' }];
241
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
242
+
243
+ assert.strictEqual(res.verifyStatus, 'failed');
244
+ const fed = lastFedVerify(messages);
245
+ assert.match(fed.content, /deny-list/i, 'the result explains the command was refused');
246
+ } finally {
247
+ await mock.close();
248
+ }
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // 7. --no-verify skips it; no command configured is a no-op
253
+ // ---------------------------------------------------------------------------
254
+
255
+ test('--no-verify skips an otherwise-failing enforcing verify; the turn ends as skipped', async () => {
256
+ const verify = { mode: 'enforcing', command: `${NODE} -e "process.exit(1)"` };
257
+ const mock = await startMockLLM();
258
+ mock.replyWith('Done.');
259
+ try {
260
+ const { runner } = buildRunner(mock.base, verify);
261
+ const { cb } = collector();
262
+ const messages = [{ role: 'user', content: 'do it' }];
263
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb, noVerify: true });
264
+
265
+ assert.strictEqual(res.verifyStatus, 'skipped');
266
+ assert.strictEqual(res.stopReason, 'end_turn');
267
+ assert.strictEqual(res.metrics.turns.length, 1, 'no verify, no re-entry');
268
+ assert.ok(!lastFedVerify(messages), 'no verify result fed into context');
269
+ } finally {
270
+ await mock.close();
271
+ }
272
+ });
273
+
274
+ test('no command configured is a no-op (skipped), even in enforcing mode', async () => {
275
+ const verify = { mode: 'enforcing', command: '' };
276
+ const mock = await startMockLLM();
277
+ mock.replyWith('Done.');
278
+ try {
279
+ const { runner } = buildRunner(mock.base, verify);
280
+ const { cb } = collector();
281
+ const messages = [{ role: 'user', content: 'do it' }];
282
+ const res = await runner.runAgentLoop(messages, 'test-model', 10, null, { callbacks: cb });
283
+
284
+ assert.strictEqual(res.verifyStatus, 'skipped');
285
+ assert.strictEqual(res.stopReason, 'end_turn');
286
+ } finally {
287
+ await mock.close();
288
+ }
289
+ });
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // 8. Headless surfaces verifyStatus
293
+ // ---------------------------------------------------------------------------
294
+
295
+ test('headless json output surfaces verifyStatus', async () => {
296
+ const verify = { mode: 'advisory', command: `${NODE} -e "process.exit(0)"` };
297
+ const mock = await startMockLLM();
298
+ mock.replyWith('Done.');
299
+ try {
300
+ const { runner } = buildRunner(mock.base, verify);
301
+ const lines = [];
302
+ await runHeadless({
303
+ runAgentLoop: runner.runAgentLoop,
304
+ messages: [{ role: 'user', content: 'do it' }],
305
+ model: 'test-model',
306
+ mode: 'json',
307
+ maxIterations: 10,
308
+ write: (s) => lines.push(s),
309
+ });
310
+ const objs = lines.join('').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
311
+ assert.strictEqual(objs.length, 1);
312
+ assert.strictEqual(objs[0].verifyStatus, 'passed', 'verifyStatus is in the json envelope');
313
+ assert.strictEqual(objs[0].stopReason, 'end_turn');
314
+ } finally {
315
+ await mock.close();
316
+ }
317
+ });
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ // Unit tests for self-verification (Task 4.2) — the pure normalizer and the
4
+ // command runner in lib/verify.js. The runner uses the REAL spawnSync via
5
+ // portable `node -e` commands so exit-code semantics, deny-list refusal, the
6
+ // no-op cases, and untrusted-fencing are all exercised directly (no agent loop).
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+
11
+ const { normalizeVerify, createVerifyRunner: _createVerifyRunner } = require('../lib/verify');
12
+ const { DEFAULT_VERIFY_TIMEOUT_MS, DEFAULT_VERIFY_MAX_ATTEMPTS } = require('../lib/constants');
13
+
14
+ const NODE = JSON.stringify(process.execPath);
15
+
16
+ // These tests exercise verify ORCHESTRATION (deny-list, exit-code semantics,
17
+ // timeout, no-op/skip, fencing) — NOT the OS sandbox, which has its own
18
+ // dedicated tests (hooks-verify-sandbox.test.js). Inject a pass-through sandbox
19
+ // resolver so the command runs plain via the 2-arg spawn(command, opts) form.
20
+ const NO_SANDBOX = (command) => ({ run: true, useShell: true, file: command, args: [], sandbox: 'off' });
21
+ const createVerifyRunner = (opts = {}) => _createVerifyRunner({ sandbox: NO_SANDBOX, ...opts });
22
+ const runnerFor = (verify) => createVerifyRunner({ getConfig: () => ({ verify }) });
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // normalizeVerify
26
+ // ---------------------------------------------------------------------------
27
+
28
+ test('normalizeVerify: defaults for empty/garbage input', () => {
29
+ for (const bad of [undefined, null, 42, 'x', [], true]) {
30
+ assert.deepStrictEqual(normalizeVerify(bad), {
31
+ mode: 'advisory',
32
+ command: '',
33
+ timeout_ms: DEFAULT_VERIFY_TIMEOUT_MS,
34
+ expected_exit_code: 0,
35
+ max_attempts: DEFAULT_VERIFY_MAX_ATTEMPTS,
36
+ });
37
+ }
38
+ });
39
+
40
+ test('normalizeVerify: accepts valid fields, rejects invalid ones', () => {
41
+ const v = normalizeVerify({
42
+ mode: 'enforcing', command: ' npm test ',
43
+ timeout_ms: 5000, expected_exit_code: 2, max_attempts: 4,
44
+ });
45
+ assert.strictEqual(v.mode, 'enforcing');
46
+ assert.strictEqual(v.command, 'npm test', 'command is trimmed');
47
+ assert.strictEqual(v.timeout_ms, 5000);
48
+ assert.strictEqual(v.expected_exit_code, 2);
49
+ assert.strictEqual(v.max_attempts, 4);
50
+
51
+ // Invalid values fall back to defaults — never unbounded/negative.
52
+ const bad = normalizeVerify({
53
+ mode: 'bogus', command: ' ', timeout_ms: 0,
54
+ expected_exit_code: -1, max_attempts: 0,
55
+ });
56
+ assert.strictEqual(bad.mode, 'advisory', 'unknown mode → advisory');
57
+ assert.strictEqual(bad.command, '', 'blank command → empty (no-op)');
58
+ assert.strictEqual(bad.timeout_ms, DEFAULT_VERIFY_TIMEOUT_MS);
59
+ assert.strictEqual(bad.expected_exit_code, 0, 'negative expected exit code rejected');
60
+ assert.strictEqual(bad.max_attempts, DEFAULT_VERIFY_MAX_ATTEMPTS, 'zero attempts rejected');
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // runner — no-op cases
65
+ // ---------------------------------------------------------------------------
66
+
67
+ test('run: no command configured is a no-op (skipped)', async () => {
68
+ const res = await runnerFor({ command: '' }).run();
69
+ assert.strictEqual(res.skipped, true);
70
+ assert.strictEqual(res.ran, false);
71
+ });
72
+
73
+ test('run: --no-verify short-circuits even with a command configured', async () => {
74
+ const res = await runnerFor({ command: `${NODE} -e "process.exit(0)"` }).run({ noVerify: true });
75
+ assert.strictEqual(res.skipped, true);
76
+ assert.strictEqual(res.ran, false);
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // runner — exit-code based success (never stdout parsing)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ test('run: exit 0 passes by default', async () => {
84
+ const res = await runnerFor({ command: `${NODE} -e "process.exit(0)"` }).run();
85
+ assert.strictEqual(res.passed, true);
86
+ assert.strictEqual(res.ran, true);
87
+ assert.strictEqual(res.exitCode, 0);
88
+ assert.match(res.output, /PASSED/);
89
+ });
90
+
91
+ test('run: nonzero exit fails by default', async () => {
92
+ const res = await runnerFor({ command: `${NODE} -e "process.exit(1)"` }).run();
93
+ assert.strictEqual(res.passed, false);
94
+ assert.strictEqual(res.exitCode, 1);
95
+ assert.match(res.output, /FAILED/);
96
+ });
97
+
98
+ test('run: a command that prints "PASS" but exits nonzero still FAILS (exit-code based, not stdout parsing)', async () => {
99
+ const res = await runnerFor({ command: `${NODE} -e "process.stdout.write('ALL TESTS PASS');process.exit(1)"` }).run();
100
+ assert.strictEqual(res.passed, false, 'stdout success words do not make a failing exit pass');
101
+ });
102
+
103
+ test('run: configurable expected_exit_code', async () => {
104
+ const res = await runnerFor({ command: `${NODE} -e "process.exit(3)"`, expected_exit_code: 3 }).run();
105
+ assert.strictEqual(res.passed, true, 'exit matches the expected non-zero code');
106
+ assert.strictEqual(res.exitCode, 3);
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // runner — deny-list, timeout, fencing
111
+ // ---------------------------------------------------------------------------
112
+
113
+ test('run: a deny-listed verify command is refused (never run) and reported non-passing', async () => {
114
+ const res = await runnerFor({ command: 'rm -rf /' }).run();
115
+ assert.strictEqual(res.passed, false);
116
+ assert.strictEqual(res.ran, false, 'the command was never executed');
117
+ assert.ok(res.denied, 'a deny-list label is recorded');
118
+ assert.match(res.output, /deny-list/i);
119
+ });
120
+
121
+ test('run: a hung command times out and is treated as a failed verify (no hang)', async () => {
122
+ const res = await runnerFor({ command: `${NODE} -e "setTimeout(function(){}, 10000)"`, timeout_ms: 300 }).run();
123
+ assert.strictEqual(res.timedOut, true);
124
+ assert.strictEqual(res.passed, false);
125
+ assert.match(res.output, /timed out/i);
126
+ });
127
+
128
+ test('run: output is fenced as untrusted external content', async () => {
129
+ const res = await runnerFor({ command: `${NODE} -e "process.stdout.write('SENTINEL_OUT_9');process.exit(1)"` }).run();
130
+ assert.match(res.fenced, /UNTRUSTED_EXTERNAL_CONTENT/, 'fenced with the standard delimiter');
131
+ assert.match(res.fenced, /SENTINEL_OUT_9/, 'the command output is inside the fence');
132
+ });
133
+
134
+ test('run: deny-list and timeout both short-circuit via an injected spawn that is never called', async () => {
135
+ let spawnCalls = 0;
136
+ const spy = () => { spawnCalls++; return { status: 0, stdout: '', stderr: '' }; };
137
+ const runner = createVerifyRunner({ getConfig: () => ({ verify: { command: 'rm -rf /' } }), spawn: spy });
138
+ const res = await runner.run();
139
+ assert.strictEqual(spawnCalls, 0, 'a deny-listed command never reaches spawn');
140
+ assert.ok(res.denied);
141
+ });
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ // Web-activity ordering (W.3 regression fix). The collapsed "✓ web · …" summary
4
+ // must commit to scrollback BEFORE the agent's answer, not after it.
5
+ //
6
+ // The W.3 regression: http_get/web_search deferred their scrollback commit from
7
+ // "tool end" to webTracker.flush(), and in a "web-op(s) → answer" turn the only
8
+ // flush that fired was the turn-end `finally` — which runs AFTER runAgentLoop
9
+ // returns, i.e. after the answer was already committed. The fix flushes the open
10
+ // web group in onAssistantMessage when cleanContent is non-empty (the terminal
11
+ // response signal), while intermediate empty-content iterations keep the group
12
+ // open so multi-step search→fetch still collapses to one line.
13
+ //
14
+ // These tests drive the REAL createTurnHandler callbacks (chat-turn.js) with a
15
+ // mock runAgentLoop that invokes them in the order agent.js does — per iteration
16
+ // onAssistantMessage(displayReply) fires first (empty '' when the iteration
17
+ // carried tool calls, non-empty on the final answer), then the tools execute —
18
+ // recording an ordered event log so we can assert "summary before answer".
19
+
20
+ const { test } = require('node:test');
21
+ const assert = require('node:assert');
22
+
23
+ const { stripAnsi } = require('../lib/ui/utils');
24
+ const { createTurnHandler } = require('../lib/commands/chat-turn');
25
+
26
+ // A fake writer + chatHistory that push into ONE shared ordered log. The web
27
+ // summary commits via writerModule.endActivity (from webTracker.flush); the
28
+ // answer commits via chatHistory.finalizeLastMessage. A non-web tool line also
29
+ // commits via endActivity — distinguished by content (formatToolLine is mocked
30
+ // to a recognizable "TOOL:<tag>" string).
31
+ function harness() {
32
+ const events = [];
33
+ const writerModule = {
34
+ startActivity() {},
35
+ updateActivity() {},
36
+ endActivity(id, line) {
37
+ const plain = stripAnsi(String(line));
38
+ if (/web\b/.test(plain) && /(source|search|web)/.test(plain) && !plain.startsWith('TOOL:')) {
39
+ events.push({ kind: 'web-summary', line: plain });
40
+ } else {
41
+ events.push({ kind: 'tool-line', line: plain });
42
+ }
43
+ },
44
+ scrollback(line) { events.push({ kind: 'scrollback', line: String(line) }); },
45
+ };
46
+ const chatHistory = {
47
+ addMessage() {},
48
+ streamToken() {},
49
+ clearStreamingContent() {},
50
+ // An empty finalize (the suppressed intermediate iteration) commits no
51
+ // visible answer bubble — only record the non-empty terminal answer, which
52
+ // is what must land below the web summary.
53
+ finalizeLastMessage(content) { if (content && content.trim()) events.push({ kind: 'answer', content }); },
54
+ };
55
+ const statusBar = {
56
+ update() {}, onToken() {}, addPendingTokens() {}, updateMetrics() {}, setCost() {},
57
+ };
58
+ const inputField = {
59
+ on() {}, removeListener() {}, releaseNavigation() {}, setDisabled() {},
60
+ };
61
+
62
+ // Set by each test before invoking the handler.
63
+ let scenario = async () => {};
64
+ const runAgentLoop = async (messages, model, maxIter, limit, loopOpts) => {
65
+ await scenario(loopOpts.callbacks);
66
+ return { messages, metrics: { turns: [] }, withheldActions: [] };
67
+ };
68
+
69
+ const ctx = {
70
+ inputField, statusBar, chatHistory, writerModule, runAgentLoop,
71
+ getConfig: () => ({ auth_token: 'tok', max_iterations: 50, show_cost: false, system_prompt_mode: 'system_role' }),
72
+ approxTokens: () => 0,
73
+ resolveCommand: () => null,
74
+ opts: {},
75
+ TAG_REGISTRY: {},
76
+ formatToolLine: (o) => `TOOL:${o && o.tag}`,
77
+ collapseListMsg() {}, handlePendingSelection() {}, showPendingStep() {},
78
+ activateNavCapture() {}, finalizeListMsg() {},
79
+ createChatIfNeeded: async () => {}, saveTurnToDashboard: async () => {}, saveSession() {},
80
+ messages: [], currentModel: 'm', debugMode: false, pendingImages: [],
81
+ chatSync: async () => '', resolvedSystemPrompt: '', resolvedTokenLimit: null, planMode: false,
82
+ };
83
+
84
+ const handler = createTurnHandler(ctx, {});
85
+ return { events, handler, setScenario: (fn) => { scenario = fn; } };
86
+ }
87
+
88
+ // Helpers to simulate the agent.js per-iteration callback order.
89
+ function webToolIteration(cb, tag, input, meta) {
90
+ cb.onAssistantMessage(''); // suppressed (this iteration had a tool call)
91
+ cb.onToolStart(tag, input, { id: `${tag}-1`, attrs: tag === 'web_search' ? { query: input } : { url: input } });
92
+ cb.onToolEnd(tag, {}, 120, { id: `${tag}-1`, attrs: tag === 'web_search' ? { query: input } : { url: input }, meta, error: null });
93
+ }
94
+
95
+ function indexOfKind(events, kind) { return events.findIndex((e) => e.kind === kind); }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // The regression: single http_get → answer commits the summary BEFORE the answer
99
+ // ---------------------------------------------------------------------------
100
+
101
+ test('single http_get → answer: web summary commits before the answer', async () => {
102
+ const h = harness();
103
+ h.setScenario(async (cb) => {
104
+ webToolIteration(cb, 'http_get', 'https://a.example', { status_code: 200, bytes: 1000 });
105
+ cb.onAssistantMessage('Here is the synthesized answer.'); // final answer iteration
106
+ });
107
+
108
+ await h.handler('summarize https://a.example');
109
+
110
+ const summaries = h.events.filter((e) => e.kind === 'web-summary');
111
+ assert.strictEqual(summaries.length, 1, 'exactly one collapsed summary');
112
+ const iSummary = indexOfKind(h.events, 'web-summary');
113
+ const iAnswer = indexOfKind(h.events, 'answer');
114
+ assert.ok(iSummary >= 0 && iAnswer >= 0, 'both committed');
115
+ assert.ok(iSummary < iAnswer, 'the web summary precedes the answer (the bug being fixed)');
116
+ assert.match(summaries[0].line, /1 source read/);
117
+ });
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // The W.3 guarantee preserved: multi-step search→fetch still collapses to ONE line
121
+ // ---------------------------------------------------------------------------
122
+
123
+ test('web_search → http_get → answer: one collapsed line, before the answer; intermediate iteration does NOT flush', async () => {
124
+ const h = harness();
125
+ h.setScenario(async (cb) => {
126
+ // Iteration 1: web_search (separate LLM round-trip from the fetch).
127
+ webToolIteration(cb, 'web_search', 'corruption scandals', null);
128
+ // Iteration 2: http_get — its onAssistantMessage('') must NOT flush, else the
129
+ // single collapsed line would split into two.
130
+ webToolIteration(cb, 'http_get', 'https://a.example', { status_code: 200, bytes: 1000 });
131
+ // Iteration 3: the final answer.
132
+ cb.onAssistantMessage('Final answer with citations.');
133
+ });
134
+
135
+ await h.handler('research corruption scandals');
136
+
137
+ const summaries = h.events.filter((e) => e.kind === 'web-summary');
138
+ assert.strictEqual(summaries.length, 1, 'multi-step web activity collapses to exactly ONE line (W.3 guarantee)');
139
+ const iSummary = indexOfKind(h.events, 'web-summary');
140
+ const iAnswer = indexOfKind(h.events, 'answer');
141
+ assert.ok(iSummary < iAnswer, 'the single collapsed summary precedes the answer');
142
+ // Both the search and the read are reflected in the one line.
143
+ assert.match(summaries[0].line, /search "corruption scandals"/);
144
+ assert.match(summaries[0].line, /1 source read/);
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Safety net: an empty / interrupted turn still flushes via the turn-end finally
149
+ // ---------------------------------------------------------------------------
150
+
151
+ test('empty/interrupted answer: summary still committed via the turn-end finally', async () => {
152
+ const h = harness();
153
+ h.setScenario(async (cb) => {
154
+ // A turn that did web work but never produced a non-empty assistant message
155
+ // (e.g. hit the iteration cap, or was interrupted). No final flush in
156
+ // onAssistantMessage — the `finally` is the safety net.
157
+ webToolIteration(cb, 'http_get', 'https://a.example', { status_code: 200, bytes: 1000 });
158
+ });
159
+
160
+ await h.handler('fetch https://a.example');
161
+
162
+ const summaries = h.events.filter((e) => e.kind === 'web-summary');
163
+ assert.strictEqual(summaries.length, 1, 'the summary is not lost — flushed in finally');
164
+ assert.strictEqual(indexOfKind(h.events, 'answer'), -1, 'no non-empty answer was finalized');
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Non-web tool after web ops: still flushes via onToolStart (unregressed)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ test('non-web tool after web ops: summary flushed before the non-web tool line', async () => {
172
+ const h = harness();
173
+ h.setScenario(async (cb) => {
174
+ // Iteration 1: http_get.
175
+ webToolIteration(cb, 'http_get', 'https://a.example', { status_code: 200, bytes: 1000 });
176
+ // Iteration 2: a non-web tool (read_file). Its onToolStart closes the open
177
+ // web group first (chat-turn.js line 211) so the summary lands above its line.
178
+ cb.onAssistantMessage('');
179
+ cb.onToolStart('read_file', '/x', { id: 'rf-1', attrs: { path: '/x' } });
180
+ cb.onToolEnd('read_file', 'contents', 5, { id: 'rf-1', attrs: { path: '/x' }, meta: null, error: null });
181
+ // Iteration 3: the answer.
182
+ cb.onAssistantMessage('Done.');
183
+ });
184
+
185
+ await h.handler('fetch then read');
186
+
187
+ const summaries = h.events.filter((e) => e.kind === 'web-summary');
188
+ assert.strictEqual(summaries.length, 1, 'one web summary');
189
+ const iSummary = indexOfKind(h.events, 'web-summary');
190
+ const iToolLine = h.events.findIndex((e) => e.kind === 'tool-line' && /read_file/.test(e.line));
191
+ const iAnswer = indexOfKind(h.events, 'answer');
192
+ assert.ok(iSummary < iToolLine, 'web summary precedes the non-web tool line (flushed by onToolStart)');
193
+ assert.ok(iToolLine < iAnswer, 'and both precede the answer');
194
+ });