@semalt-ai/code 1.8.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ // Output Refactor — Phase 6c-i: persist web-op cores, with ZERO visible change.
4
+ //
5
+ // Web tools (web_search/http_get) are intercepted in chat-turn.js BEFORE they
6
+ // become a normal ToolOperation descriptor (they collapse into the live web
7
+ // summary). Before 6c-i the interception returned `undefined`, so the agent
8
+ // loop's `displayCore || null` push stored a `null` slot and the web op fell to
9
+ // the legacy whole-blob / summarizeToolResult fallback on replay. 6c-i makes the
10
+ // interception return a dedicated web-op core `{v:1,kind:'web',…}` that the slot
11
+ // now carries on BOTH rails — while every replay reader is taught to treat a
12
+ // web-core as "no descriptor → fallback". Net contract: replay output is
13
+ // BYTE-IDENTICAL to today. Aggregation lands in 6c-ii, not here.
14
+ //
15
+ // These tests pin:
16
+ // 1. POSITIVE — serializeWebOp sources fields from ctx; the core lands in the
17
+ // slot on the native {role:'tool'} message AND the XML `_display[]` array.
18
+ // 2. 6c-ii FLIP (headline) — replay of a native web message and of an XML blob
19
+ // containing a web op now AGGREGATES the web-core(s) into the committed
20
+ // `✓ web · …` summary, byte-identical to the live committed line. (These were
21
+ // 6c-i "no-op replay" cases; 6c-ii deliberately makes web activity visible on
22
+ // replay. Persistence/shape/inv.1/live-tracker pins below are unchanged.)
23
+ // 3. INV.1 — `content` byte-identical; the web-core is never folded into content.
24
+ // 4. ANTI-PING-PONG — fresh live web display unchanged; 6a/6b normal-tool parity
25
+ // and Phase 1 fresh-render still byte-identical.
26
+
27
+ const { test } = require('node:test');
28
+ const assert = require('node:assert');
29
+
30
+ // Force a colour-capable env so byte comparisons are stable (node:test runs each
31
+ // file in its own process — no leak to other suites). Mirrors the 6a/6b suites.
32
+ process.stdout.isTTY = true;
33
+ delete process.env.NO_COLOR;
34
+
35
+ const { buildToolOperation, serializeOperation, descriptorFromStored } = require('../lib/ui/tool-operation');
36
+ const { renderOperation } = require('../lib/ui/render-operation');
37
+ const { ChatHistory } = require('../lib/ui/chat-history');
38
+ const { createChatSession } = require('../lib/commands/chat-session');
39
+ const {
40
+ serializeWebOp,
41
+ isWebCore,
42
+ aggregateWebOps,
43
+ webSummaryText,
44
+ createWebActivityTracker,
45
+ } = require('../lib/ui/web-activity');
46
+
47
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
48
+ const CFG = { diff_max_lines: 50, shell_preview_lines: 5 };
49
+
50
+ // A normal (non-web) fixture used to prove a MIXED blob (normal + web) still
51
+ // drops to the whole-blob summary — i.e. the gate fails on the web slot.
52
+ const EDIT_DIFF = buildToolOperation({
53
+ id: 'tool-1', tag: 'edit_file', arg: 'lib/x.js', attrs: { path: 'lib/x.js' },
54
+ status: 'ok', durationMs: 12, diff: { before: 'a\nb\nc\n', after: 'a\nB\nc\n', path: 'lib/x.js' },
55
+ });
56
+
57
+ // Tool ctx objects exactly as onToolEnd receives them: { attrs, meta, error }.
58
+ // web_search with a query + a 200/bytes meta; http_get error case with a url and
59
+ // a transport error (no status/bytes).
60
+ const SEARCH_CTX = { attrs: { query: 'коррупционные скандалы 2024' }, meta: { status_code: 200, bytes: 4096 }, error: null };
61
+ const FETCH_ERR_CTX = { attrs: { url: 'https://example.com/blocked' }, meta: null, error: { message: 'Request timeout' } };
62
+
63
+ // ── Faithful models of the agent.js per-rail persistence of onToolEnd's return.
64
+ // Native: _nativeToolMessage attaches `_display` only when truthy (agent.js:352-355),
65
+ // receiving the already-`|| null`-ed slot. XML: messages.push({…, _display:
66
+ // displayCores.slice()}) with each slot = `displayCore || null` (agent.js:1959-1963).
67
+ function nativeToolMsg(content, core) {
68
+ const slot = core || null; // the agent loop's `displayCore || null` push
69
+ const msg = { role: 'tool', content };
70
+ if (slot) msg._display = slot; // _nativeToolMessage: attach only when truthy
71
+ return msg;
72
+ }
73
+ function xmlBlob(results, cores) {
74
+ const m = {
75
+ role: 'user',
76
+ content: `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
77
+ };
78
+ if (cores !== undefined) m._display = cores.map((c) => c || null); // `displayCore || null` per slot
79
+ return m;
80
+ }
81
+
82
+ // Drive the REAL replay (chat-session.displayLoadedMessages) over one loaded
83
+ // message and capture exactly what is committed to scrollback.
84
+ function replay(loadedMessage, cfg) {
85
+ const ch = new ChatHistory();
86
+ const out = [];
87
+ ch._commit = (t) => out.push(t);
88
+ const session = createChatSession({ chatHistory: ch, getConfig: () => cfg || CFG });
89
+ session.displayLoadedMessages([loadedMessage]);
90
+ return out.join('');
91
+ }
92
+
93
+ // ───────────────────────────────────────────────────────────────────────────
94
+ // 1. POSITIVE — serializeWebOp sources fields from ctx, and the core lands in the
95
+ // slot on BOTH rails (guards web-op persistence on native + XML).
96
+ // ───────────────────────────────────────────────────────────────────────────
97
+ test('serializeWebOp: sources query/status/bytes (search) and url/error (fetch) from ctx, flat for aggregateWebOps (guards core shape)', () => {
98
+ const search = serializeWebOp(SEARCH_CTX, 'web_search', 300);
99
+ assert.deepStrictEqual(search, {
100
+ v: 1, kind: 'web', tag: 'web_search',
101
+ query: 'коррупционные скандалы 2024', url: undefined,
102
+ status: 200, bytes: 4096, error: undefined, durationMs: 300,
103
+ });
104
+ const fetch = serializeWebOp(FETCH_ERR_CTX, 'http_get', 50);
105
+ assert.deepStrictEqual(fetch, {
106
+ v: 1, kind: 'web', tag: 'http_get',
107
+ query: undefined, url: 'https://example.com/blocked',
108
+ status: undefined, bytes: undefined, error: 'Request timeout', durationMs: 50,
109
+ });
110
+ // The flat fields are exactly what aggregateWebOps reads (so 6c-ii can feed
111
+ // these cores directly): 1 search query, and the errored fetch shows as blocked.
112
+ const text = webSummaryText(aggregateWebOps([search, fetch]));
113
+ assert.match(text, /search "коррупционные/);
114
+ assert.match(text, /0 sources read/);
115
+ assert.match(text, /1 blocked/);
116
+ // Both are recognized as web-cores; a normal descriptor core is NOT.
117
+ assert.ok(isWebCore(search) && isWebCore(fetch), 'web-cores recognized');
118
+ assert.ok(!isWebCore(serializeOperation(EDIT_DIFF)), 'a normal descriptor core is not a web-core');
119
+ });
120
+
121
+ test('persistence: the web-op core lands in the slot on the native {role:tool} message AND the XML _display[] array (guards both rails)', () => {
122
+ const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
123
+ // Native: truthy core → attached verbatim as `_display`.
124
+ const native = nativeToolMsg('web search "…" (5 results)', core);
125
+ assert.deepStrictEqual(native._display, core, 'native {role:tool}._display carries the web-core');
126
+ // XML: the slot that used to be `null` now holds the web-core.
127
+ const blob = xmlBlob(['edited lib/x.js', 'fetched'], [serializeOperation(EDIT_DIFF), core]);
128
+ assert.deepStrictEqual(blob._display[1], core, 'XML _display[] slot carries the web-core (no longer null)');
129
+ });
130
+
131
+ // ───────────────────────────────────────────────────────────────────────────
132
+ // 2. 6c-ii FLIP (headline visible change) — replay now AGGREGATES web-cores into
133
+ // the committed `✓ web · …` summary instead of the legacy fallback. These two
134
+ // were the 6c-i "no-op replay" cases; 6c-ii deliberately makes them visible.
135
+ // Full oracle-parity coverage (interleaving, cross-iteration, both rails) lives
136
+ // in test/replay-web-aggregate.test.js — these pin the persist-file's own rails.
137
+ // ───────────────────────────────────────────────────────────────────────────
138
+
139
+ // Drive the live tracker over a single op and return its committed summary line —
140
+ // the byte-exact oracle a replayed web summary must reproduce.
141
+ function liveOracle(tag, input, ctx, durationMs) {
142
+ const frames = [];
143
+ const tracker = createWebActivityTracker({ writerModule: {
144
+ startActivity: () => {}, updateActivity: () => {},
145
+ endActivity: (_id, line) => frames.push(line),
146
+ } });
147
+ tracker.start(tag, input);
148
+ tracker.end(tag, 'done', durationMs, ctx);
149
+ tracker.flush();
150
+ return frames[frames.length - 1];
151
+ }
152
+
153
+ test('6c-ii: a native web {role:tool} message now replays as the aggregated web summary, byte-identical to live (flip of 6c-i invisibility)', () => {
154
+ const core = serializeWebOp(FETCH_ERR_CTX, 'http_get', 120);
155
+ const oracle = liveOracle('http_get', 'https://example.com/blocked', FETCH_ERR_CTX, 120);
156
+ const committed = replay(nativeToolMsg('web · GET https://example.com\n<page body…>', core));
157
+ assert.strictEqual(committed, oracle, 'native web message replays as the aggregated summary, byte-identical to the live committed line');
158
+ // Not vacuous, and it IS the aggregated web summary (not the legacy fallback).
159
+ assert.match(stripAnsi(committed), /web/);
160
+ assert.match(stripAnsi(committed), /1 blocked/);
161
+ });
162
+
163
+ test('6c-ii: an XML blob mixing a normal slot + a web-op slot now renders the normal op per-slot AND aggregates the web op below it (gate passes)', () => {
164
+ const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
165
+ const results = ['edited lib/x.js', 'web search results…'];
166
+ // The normal slot alone replays per-slot (6b path) — same render the mixed blob
167
+ // must reproduce for its non-web slot.
168
+ const editOnly = replay(xmlBlob(results, [serializeOperation(EDIT_DIFF)]));
169
+ const webOracle = liveOracle('web_search', SEARCH_CTX.attrs.query, SEARCH_CTX, 300);
170
+ const mixed = replay(xmlBlob(results, [serializeOperation(EDIT_DIFF), core]));
171
+ assert.strictEqual(mixed, editOnly + webOracle, 'normal slot renders per-slot; the web slot aggregates into the summary committed below it');
172
+ // The non-web slot IS now rendered individually (its diff body appears) — the
173
+ // 6c-i whole-blob fallback no longer applies once every slot is a valid core.
174
+ const diffBody = renderOperation(descriptorFromStored(serializeOperation(EDIT_DIFF)), { mode: 'ansi', phase: 'detail', maxLines: 50 });
175
+ assert.ok(diffBody.length > 0 && mixed.includes(diffBody), 'the non-web slot is rendered per-slot (no whole-blob fallback)');
176
+ // And the web op is no longer hidden: its summary is visible.
177
+ assert.match(stripAnsi(mixed), /web/);
178
+ });
179
+
180
+ // ───────────────────────────────────────────────────────────────────────────
181
+ // 3. INV.1 — `content` is byte-identical; the web-core never enters `content`.
182
+ // ───────────────────────────────────────────────────────────────────────────
183
+ test('inv.1: attaching the web-core leaves the XML feedback content byte-identical and out of the model-facing string (guards the chokepoint)', () => {
184
+ const core = serializeWebOp(SEARCH_CTX, 'web_search', 300);
185
+ const results = ['edited lib/x.js', 'web search results…'];
186
+ const expectedContent = `Tool execution results:\n\n${results.join('\n\n')}\n\nContinue with the task. If everything is done, summarize what was accomplished.`;
187
+ const blob = xmlBlob(results, [serializeOperation(EDIT_DIFF), core]);
188
+ assert.strictEqual(blob.content, expectedContent, 'content equals the results.join-wrapped string, byte-for-byte');
189
+ assert.ok(!blob.content.includes('kind'), 'no web-core framing leaked into content');
190
+ assert.ok(!blob.content.includes('коррупционные'), 'the query lives only in the core, not content');
191
+ // Native rail: the web-core is a sibling key, never inside `content`.
192
+ const native = nativeToolMsg('body', core);
193
+ assert.ok(!native.content.includes('kind') && !native.content.includes('web'), 'native content carries no core framing');
194
+ });
195
+
196
+ // ───────────────────────────────────────────────────────────────────────────
197
+ // 4. ANTI-PING-PONG — live web display unchanged; 6a/6b + Phase 1 still identical.
198
+ // ───────────────────────────────────────────────────────────────────────────
199
+ test('anti-ping-pong: the live web tracker render is unchanged — nothing in the live path reads serializeWebOp\'s return (guards live region)', () => {
200
+ // A fake writer captures exactly what the live tracker renders.
201
+ const frames = [];
202
+ const writerModule = {
203
+ startActivity: (_id, fn) => frames.push(stripAnsi(fn(0))),
204
+ updateActivity: (_id, fn) => frames.push(stripAnsi(fn(0))),
205
+ endActivity: (_id, line) => frames.push(stripAnsi(line)),
206
+ };
207
+ const tracker = createWebActivityTracker({ writerModule });
208
+ tracker.start('web_search', 'коррупционные скандалы 2024');
209
+ tracker.end('web_search', 'ok', 300, SEARCH_CTX);
210
+ // The interception ALSO calls serializeWebOp — assert calling it does not alter
211
+ // what the tracker subsequently renders (the return value is persistence-only).
212
+ serializeWebOp(SEARCH_CTX, 'web_search', 300);
213
+ tracker.flush();
214
+ const committed = frames[frames.length - 1];
215
+ assert.match(committed, /web/);
216
+ assert.match(committed, /search/);
217
+ // The committed line is exactly the tracker's own summary — untouched by 6c-i.
218
+ assert.strictEqual(committed, stripAnsi(require('../lib/ui/web-activity').formatWebSummaryLine(aggregateWebOps([
219
+ { tag: 'web_search', query: 'коррупционные скандалы 2024', status: 200, bytes: 4096 },
220
+ ]), { pending: false })));
221
+ });
222
+
223
+ test('anti-ping-pong: native (6a) normal-tool round-trip and Phase 1 fresh-render bytes unchanged (guards no regression)', () => {
224
+ // 6a: a normal descriptor still round-trips byte-identical (the web guard does
225
+ // not touch the non-web path).
226
+ const restored = descriptorFromStored(serializeOperation(EDIT_DIFF));
227
+ assert.strictEqual(
228
+ renderOperation(restored, { mode: 'ansi' }),
229
+ renderOperation(EDIT_DIFF, { mode: 'ansi' }),
230
+ 'native rail result line round-trips byte-identical',
231
+ );
232
+ // Phase 1: the canonical fresh shell line bytes are pinned (same oracle as 6b).
233
+ const shellOk = renderOperation(
234
+ buildToolOperation({ status: 'success', tag: 'shell', arg: 'npm install', attrs: { command: 'npm install' }, durationMs: 2300, meta: { exit_code: 0 } }),
235
+ { mode: 'ansi', phase: 'result' },
236
+ );
237
+ assert.strictEqual(
238
+ shellOk,
239
+ ' \x1b[38;5;40m✓\x1b[0m \x1b[38;5;214mshell\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;214mnpm install\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244m2.3s\x1b[0m \x1b[2m·\x1b[0m \x1b[38;5;244mexit 0\x1b[0m',
240
+ );
241
+ });
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+
3
+ // Task W.8 — Cap MCP & subagent output entering context.
4
+ //
5
+ // THE CHANGE these tests pin: MCP tool results (lib/mcp/client.js) and subagent
6
+ // final text (lib/subagents.js) were the last two UNBOUNDED paths into context —
7
+ // both fenced as untrusted, but neither token-capped. A server (MCP) or a verbose
8
+ // child (subagent) could blow context wholesale. Both serializers now apply the
9
+ // standard capToTokens (consistent with W.5–W.7) BEFORE wrapping the text in the
10
+ // untrusted fence, with DIFFERENT budgets:
11
+ // * MCP — STRICTER (third-party, untrusted, server-controlled): the riskiest.
12
+ // * Subagent — GENEROUS (our own child's synthesized result): a safety net.
13
+ // Tests assert the MODEL-FACING (and parent-facing) result: the bound, the
14
+ // truncation notice, the fence-still-present, and that the two budgets differ.
15
+
16
+ const { test, before, after, afterEach } = require('node:test');
17
+ const assert = require('node:assert');
18
+
19
+ const ui = require('../lib/ui');
20
+ const { createApiClient } = require('../lib/api');
21
+ const { createToolExecutor, extractToolCalls } = require('../lib/tools');
22
+ const { createPermissionManager } = require('../lib/permissions');
23
+ const {
24
+ createAgentRunner, formatMcpResult, formatSubagentResult,
25
+ } = require('../lib/agent');
26
+ const toolRegistry = require('../lib/tool_registry');
27
+ const { createSubagentManager, buildSpawnAgentEntry } = require('../lib/subagents');
28
+ const {
29
+ DEFAULT_MCP_MAX_RESULT_TOKENS, DEFAULT_SUBAGENT_MAX_RESULT_TOKENS,
30
+ } = require('../lib/constants');
31
+ const { startMockLLM } = require('./harness/mock-llm');
32
+
33
+ const FENCE_OPEN = /<<<UNTRUSTED_EXTERNAL_CONTENT/;
34
+ const FENCE_CLOSE = /<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>/;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Part A — pure model-facing serializers (formatMcpResult / formatSubagentResult)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ test('MCP: small result passes through fully, no notice, still fenced', () => {
41
+ const content = 'just a small payload from the server';
42
+ const out = formatMcpResult({ action: 'mcp__srv__tool', content, maxTokens: 10000 });
43
+ assert.match(out, /MCP tool mcp__srv__tool result:/);
44
+ assert.match(out, FENCE_OPEN);
45
+ assert.match(out, FENCE_CLOSE);
46
+ assert.ok(out.includes(content), 'full payload present');
47
+ assert.doesNotMatch(out, /capped at/);
48
+ });
49
+
50
+ test('MCP: large result is capped with a notice, INSIDE the untrusted fence', () => {
51
+ const content = 'x'.repeat(4000); // ~1000 tokens
52
+ const out = formatMcpResult({ action: 'mcp__srv__tool', content, maxTokens: 50 });
53
+ assert.match(out, /capped at ~50 tokens \(was ~\d+\)/, 'truncation notice present');
54
+ // The capped content (and its notice) must remain BETWEEN the fence delimiters.
55
+ const open = out.indexOf('<<<UNTRUSTED_EXTERNAL_CONTENT');
56
+ const close = out.indexOf('<<<END_UNTRUSTED_EXTERNAL_CONTENT>>>');
57
+ const noticeAt = out.indexOf('capped at');
58
+ assert.ok(open >= 0 && close > open, 'fence present and well-ordered');
59
+ assert.ok(noticeAt > open && noticeAt < close, 'notice sits inside the fence');
60
+ // The full payload did NOT enter context.
61
+ assert.ok(out.length < content.length, 'result is shorter than the raw payload');
62
+ });
63
+
64
+ test('MCP: isError surfaces the error note, still fenced', () => {
65
+ const out = formatMcpResult({ action: 'mcp__srv__t', content: 'boom', isError: true, maxTokens: 10000 });
66
+ assert.match(out, /\(the tool reported an error\)/);
67
+ assert.match(out, FENCE_OPEN);
68
+ });
69
+
70
+ test('subagent: short result passes through fully, no notice, fenced', () => {
71
+ const content = 'CHILD FINDINGS: the project is a CLI';
72
+ const out = formatSubagentResult({ count: 1, content, maxTokens: 20000 });
73
+ assert.match(out, /Result from 1 subagent/);
74
+ assert.match(out, FENCE_OPEN);
75
+ assert.match(out, FENCE_CLOSE);
76
+ assert.ok(out.includes(content));
77
+ assert.doesNotMatch(out, /capped at/);
78
+ });
79
+
80
+ test('subagent: long result is capped with a notice', () => {
81
+ const content = 'y'.repeat(4000);
82
+ const out = formatSubagentResult({ count: 1, content, maxTokens: 50 });
83
+ assert.match(out, /capped at ~50 tokens \(was ~\d+\)/);
84
+ assert.match(out, FENCE_OPEN);
85
+ assert.match(out, FENCE_CLOSE);
86
+ assert.ok(out.length < content.length);
87
+ });
88
+
89
+ test('subagent: plural label for multiple subagents', () => {
90
+ const out = formatSubagentResult({ count: 3, content: 'a', maxTokens: 20000 });
91
+ assert.match(out, /Result from 3 subagents/);
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Part B — the two budgets are DISTINCT and MCP is STRICTER
96
+ // ---------------------------------------------------------------------------
97
+
98
+ test('default budgets: MCP is strictly stricter than subagent', () => {
99
+ assert.ok(DEFAULT_MCP_MAX_RESULT_TOKENS < DEFAULT_SUBAGENT_MAX_RESULT_TOKENS,
100
+ 'MCP budget must be stricter than the subagent budget');
101
+ });
102
+
103
+ test('budgets differ: content between the two budgets is capped under MCP but passes under subagent', () => {
104
+ // Size the content so its estimate is ABOVE the MCP default and BELOW the
105
+ // subagent default (estimate ≈ chars/4). Midpoint of the two budgets.
106
+ const midTokens = Math.floor((DEFAULT_MCP_MAX_RESULT_TOKENS + DEFAULT_SUBAGENT_MAX_RESULT_TOKENS) / 2);
107
+ const content = 'z'.repeat(midTokens * 4);
108
+
109
+ // No explicit maxTokens → each serializer uses ITS OWN default budget.
110
+ const mcp = formatMcpResult({ action: 'mcp__s__t', content });
111
+ const sub = formatSubagentResult({ count: 1, content });
112
+
113
+ assert.match(mcp, /capped at/, 'MCP caps a payload above its stricter budget');
114
+ assert.doesNotMatch(sub, /capped at/, 'subagent passes the same payload under its generous budget');
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Part C — through the REAL agent loop (the wiring reads config; fence intact)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ let prevKey;
122
+ before(() => { prevKey = process.env.SEMALT_API_KEY; process.env.SEMALT_API_KEY = 'test-key'; });
123
+ after(() => {
124
+ if (prevKey === undefined) delete process.env.SEMALT_API_KEY;
125
+ else process.env.SEMALT_API_KEY = prevKey;
126
+ });
127
+ afterEach(() => { toolRegistry.clearDynamicTools(); });
128
+
129
+ // Build a full parent stack (api + permissions + executors + agent runner). With
130
+ // `withSubagent` it also wires a subagent manager from the SAME building blocks
131
+ // and registers the spawn_agent tool — mirroring test/subagents-agent.test.js.
132
+ function buildStack(base, config, { withSubagent = false } = {}) {
133
+ const cfg = {
134
+ api_base: base, api_key: 'test-key', default_model: 'test-model',
135
+ temperature: 0.5, request_timeout_ms: 5000, stream: true, models: [],
136
+ ...config,
137
+ };
138
+ const getConfig = () => cfg;
139
+ const api = createApiClient({ getConfig, saveConfig: (c) => Object.assign(cfg, c), ui });
140
+ const pm = createPermissionManager(ui, { skipPermissions: true });
141
+ pm.setUICallbacks({ onAddMessage: () => {}, onShowModal: () => {}, onCloseModal: () => {}, onCaptureNavigation: () => () => {} });
142
+ const { agentExecShell, agentExecFile, describePermission } = createToolExecutor(pm, ui, getConfig);
143
+ const runner = createAgentRunner({
144
+ chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
145
+ describePermission, permissionManager: pm, ui, getConfig,
146
+ });
147
+ if (withSubagent) {
148
+ const manager = createSubagentManager({
149
+ chatStream: api.chatStream, extractToolCalls, agentExecShell, agentExecFile,
150
+ describePermission, permissionManager: pm, ui, getConfig,
151
+ });
152
+ toolRegistry.registerDynamicTool(buildSpawnAgentEntry(manager));
153
+ }
154
+ return { runner, getConfig, cfg };
155
+ }
156
+
157
+ // Register a fake MCP-style dynamic tool returning a fixed payload, so we exercise
158
+ // the formatFileResult MCP branch WITHOUT the real SDK / a live server.
159
+ function registerFakeMcpTool(content) {
160
+ toolRegistry.registerDynamicTool({
161
+ tool: 'mcp__test__big',
162
+ mcp: true,
163
+ server: 'test',
164
+ spec: { description: 'fake', parameters: { type: 'object', properties: {} } },
165
+ fromParams: (p) => ['mcp__test__big', p || {}],
166
+ parseXml: () => [],
167
+ permission: () => null,
168
+ execute: async () => ({ mcp: true, content, isError: false }),
169
+ });
170
+ }
171
+
172
+ test('real loop: a large MCP result is capped + still fenced in the tool message', async () => {
173
+ const mock = await startMockLLM();
174
+ registerFakeMcpTool('Q'.repeat(4000)); // ~1000 tokens
175
+ mock.replyWithToolCall('mcp__test__big', {});
176
+ mock.replyWith('done');
177
+ try {
178
+ const { runner } = buildStack(mock.base, { mcp: { servers: {}, max_result_tokens: 20 } });
179
+ const messages = [{ role: 'user', content: 'call the mcp tool' }];
180
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: { onError: () => {} } });
181
+ const toolMsg = messages.find((m) => m.role === 'tool' && /mcp__test__big/.test(m.content || ''));
182
+ assert.ok(toolMsg, 'MCP result fed back');
183
+ assert.match(toolMsg.content, FENCE_OPEN, 'still fenced after capping');
184
+ assert.match(toolMsg.content, FENCE_CLOSE);
185
+ assert.match(toolMsg.content, /capped at ~20 tokens/, 'capped at the configured MCP budget');
186
+ assert.ok(toolMsg.content.length < 4000, 'the full payload did not enter context');
187
+ } finally {
188
+ await mock.close();
189
+ }
190
+ });
191
+
192
+ test('real loop: a small MCP result passes through fully (paired positive), still fenced', async () => {
193
+ const mock = await startMockLLM();
194
+ registerFakeMcpTool('tiny payload');
195
+ mock.replyWithToolCall('mcp__test__big', {});
196
+ mock.replyWith('done');
197
+ try {
198
+ const { runner } = buildStack(mock.base, { mcp: { servers: {}, max_result_tokens: 10000 } });
199
+ const messages = [{ role: 'user', content: 'call the mcp tool' }];
200
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: { onError: () => {} } });
201
+ const toolMsg = messages.find((m) => m.role === 'tool' && /mcp__test__big/.test(m.content || ''));
202
+ assert.ok(toolMsg);
203
+ assert.match(toolMsg.content, FENCE_OPEN);
204
+ assert.ok(toolMsg.content.includes('tiny payload'));
205
+ assert.doesNotMatch(toolMsg.content, /capped at/);
206
+ } finally {
207
+ await mock.close();
208
+ }
209
+ });
210
+
211
+ test('real loop: a verbose subagent final text is capped + still fenced, isolation intact', async () => {
212
+ const mock = await startMockLLM();
213
+ const longChild = 'L'.repeat(4000); // ~1000 tokens
214
+ mock.replyWithToolCall('spawn_agent', { prompt: 'go research' }); // parent
215
+ mock.replyWith(longChild); // child final
216
+ mock.replyWith('noted'); // parent final
217
+ try {
218
+ const { runner } = buildStack(mock.base,
219
+ { subagents: { max_concurrency: 3, max_result_tokens: 30 } }, { withSubagent: true });
220
+ const messages = [{ role: 'user', content: 'investigate' }];
221
+ await runner.runAgentLoop(messages, 'test-model', 5, null, { callbacks: { onError: () => {} } });
222
+ const toolMsg = messages.find((m) => m.role === 'tool' && /UNTRUSTED_EXTERNAL_CONTENT/.test(m.content || ''));
223
+ assert.ok(toolMsg, 'subagent result fed back fenced');
224
+ assert.match(toolMsg.content, FENCE_OPEN);
225
+ assert.match(toolMsg.content, FENCE_CLOSE);
226
+ assert.match(toolMsg.content, /capped at ~30 tokens/, 'capped at the configured subagent budget');
227
+ // Isolation unchanged: the parent did not absorb the child's long assistant turn.
228
+ const absorbed = messages.some((m) => m.role === 'assistant' && m.content === longChild);
229
+ assert.ok(!absorbed, 'the child assistant turn never lands in the parent history');
230
+ } finally {
231
+ await mock.close();
232
+ }
233
+ });
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ // Animated running op row (Output Refactor — Phase 3, Part 2).
4
+ //
5
+ // Before Phase 3 a running tool's glyph was a static dot (●, colored after
6
+ // Phase 2.5 but not moving) and the elapsed meter only advanced as a side
7
+ // effect of the status-bar timers firing. Phase 3 animates the running glyph:
8
+ // the `tool` SPINNER_DEF frames in the category-tinted pending colour, with the
9
+ // frame derived from the elapsed duration so it advances every ~100 ms as the
10
+ // single driver repaints the row with a fresh elapsedMs. The elapsed meter
11
+ // rides the same repaint.
12
+ //
13
+ // These tests exercise the render path the writer's activity region invokes:
14
+ // renderOperation(descriptor, { phase: 'pending' }) → formatToolLine.
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert');
18
+
19
+ const { SPINNER_DEFS } = require('../lib/ui/ansi');
20
+ const { formatToolLine } = require('../lib/ui/format');
21
+ const { renderOperation } = require('../lib/ui/render-operation');
22
+
23
+ // Strip SGR so we can inspect the visible glyph/elapsed text.
24
+ function plain(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
25
+ function firstGlyph(s) { return plain(s).trimStart()[0]; }
26
+
27
+ const TOOL_FRAMES = SPINNER_DEFS.tool.frames;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // The running glyph cycles the tool spinner frames as elapsed advances.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ test('running glyph cycles spinner frames across ticks (elapsed-derived)', () => {
34
+ const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
35
+ // Frame is floor(elapsedMs / 100) % frames.length. Sample one full cycle.
36
+ for (let i = 0; i < TOOL_FRAMES.length; i++) {
37
+ const line = formatToolLine({ ...base, durationMs: i * 100 });
38
+ assert.strictEqual(firstGlyph(line), TOOL_FRAMES[i], `frame ${i} glyph`);
39
+ }
40
+ // Wraps around (modulo frames.length).
41
+ const wrapped = formatToolLine({ ...base, durationMs: TOOL_FRAMES.length * 100 });
42
+ assert.strictEqual(firstGlyph(wrapped), TOOL_FRAMES[0], 'frame index wraps');
43
+ });
44
+
45
+ test('the glyph actually changes between consecutive ~100ms samples', () => {
46
+ const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
47
+ const g0 = firstGlyph(formatToolLine({ ...base, durationMs: 0 }));
48
+ const g1 = firstGlyph(formatToolLine({ ...base, durationMs: 100 }));
49
+ const g2 = firstGlyph(formatToolLine({ ...base, durationMs: 200 }));
50
+ assert.notStrictEqual(g0, g1, 'glyph advances 0 → 100ms');
51
+ assert.notStrictEqual(g1, g2, 'glyph advances 100 → 200ms');
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // The elapsed meter advances with the duration (the frozen-timer fix).
56
+ // ---------------------------------------------------------------------------
57
+
58
+ test('the running elapsed meter advances as durationMs grows', () => {
59
+ const base = { status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' }, category: 'shell' };
60
+ const at1 = plain(formatToolLine({ ...base, durationMs: 1200 }));
61
+ const at3 = plain(formatToolLine({ ...base, durationMs: 3400 }));
62
+ assert.ok(at1.includes('1.2s'), `elapsed shows 1.2s: ${at1}`);
63
+ assert.ok(at3.includes('3.4s'), `elapsed shows 3.4s: ${at3}`);
64
+ // Pending lines trail the duration with an ellipsis.
65
+ assert.ok(at1.includes('1.2s…'), 'pending duration trails with …');
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Blocking tools (ask_user, rendered noDuration) keep the static dot — a
70
+ // ticking spinner would falsely imply work is happening.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ test('blocking (noDuration) pending tools keep the static dot, not a spinner', () => {
74
+ const line = formatToolLine({
75
+ status: 'pending', tag: 'ask_user', arg: 'Pick one', attrs: { question: 'Pick one' },
76
+ category: 'tool', noDuration: true,
77
+ });
78
+ assert.strictEqual(firstGlyph(line), '●', 'frozen blocking glyph stays the pending dot');
79
+ assert.ok(!TOOL_FRAMES.includes(firstGlyph(line)), 'not a spinner frame');
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // The descriptor path (renderOperation, pending phase) animates the same way.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ test('renderOperation pending phase animates the glyph via durationMs', () => {
87
+ const mk = (durationMs) => renderOperation(
88
+ {
89
+ status: 'pending', tag: 'read_file', target: 'a.txt', attrs: { path: 'a.txt' },
90
+ category: 'file', durationMs,
91
+ },
92
+ { mode: 'ansi', phase: 'pending' },
93
+ );
94
+ const g0 = firstGlyph(mk(0));
95
+ const g1 = firstGlyph(mk(100));
96
+ assert.strictEqual(g0, TOOL_FRAMES[0], 'descriptor pending → spinner frame 0 at 0ms');
97
+ assert.notStrictEqual(g0, g1, 'descriptor pending glyph advances with elapsed');
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Single physical row: the animated running line carries no newline (Phase 4
102
+ // owns wrap-aware multi-row; this phase must not introduce multi-row content).
103
+ // ---------------------------------------------------------------------------
104
+
105
+ test('the animated running row is a single physical line (no newline)', () => {
106
+ const line = formatToolLine({
107
+ status: 'pending', tag: 'shell', arg: 'ls', attrs: { command: 'ls' },
108
+ category: 'shell', durationMs: 500,
109
+ });
110
+ assert.ok(!line.includes('\n'), 'no newline in the running row');
111
+ });