mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -21,8 +21,24 @@ import {
21
21
  consumeCompatResponsesStream,
22
22
  isInvalidToolArgsMarker,
23
23
  } from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
24
- import { parseToolCalls as geminiParseToolCalls } from '../src/runtime/agent/orchestrator/providers/gemini.mjs';
24
+ import {
25
+ consumeCompatChatCompletionStream,
26
+ } from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
27
+ import {
28
+ createGeminiTextLeakGuard,
29
+ parseToolCalls as geminiParseToolCalls,
30
+ } from '../src/runtime/agent/orchestrator/providers/gemini.mjs';
25
31
  import { parseSSEStream as anthropicParseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
32
+ import { _buildRequestBodyForCacheSmoke } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
33
+ import {
34
+ EFFORT_BETA_HEADER,
35
+ LEGACY_EFFORT_BUDGET,
36
+ modelSupportsEffort,
37
+ modelSupportsMaxEffort,
38
+ normalizeAnthropicEffortInput,
39
+ shouldIncludeEffortBeta,
40
+ } from '../src/runtime/agent/orchestrator/providers/anthropic-effort.mjs';
41
+ import { buildAnthropicBetaHeaders } from '../src/runtime/agent/orchestrator/providers/anthropic-betas.mjs';
26
42
  import { PATCH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs';
27
43
 
28
44
  // --- Helpers ---------------------------------------------------------------
@@ -231,6 +247,47 @@ test('gemini: no functionCall parts → undefined', () => {
231
247
  assert.equal(geminiParseToolCalls([]), undefined);
232
248
  });
233
249
 
250
+ test('gemini leak guard: leaked <invoke> in part.text for known tool → recovered, no leak', () => {
251
+ const leaked = 'Sure.\n<function_calls>\n<invoke name="read">\n<parameter name="path">a.txt</parameter>\n</invoke>\n</function_calls>';
252
+ const texts = [];
253
+ const captured = [];
254
+ const guard = createGeminiTextLeakGuard({
255
+ knownToolNames: LEAK_TOOLS,
256
+ onTextDelta: (t) => texts.push(t),
257
+ onToolCall: (c) => captured.push(c),
258
+ });
259
+ guard.feedText(leaked);
260
+ guard.finalize();
261
+ const content = guard.scrubAssistantText(leaked);
262
+ const emitted = texts.join('');
263
+ assert.equal(/<invoke|<function_calls|<parameter/.test(emitted), false);
264
+ assert.equal(/<invoke|<function_calls|<parameter/.test(content), false);
265
+ assert.ok(emitted.includes('Sure.'));
266
+ assert.ok(content.includes('Sure.'));
267
+ const leakedCalls = guard.getLeakedToolCalls();
268
+ assert.equal(leakedCalls.length, 1);
269
+ assert.equal(leakedCalls[0].name, 'read');
270
+ assert.deepEqual(leakedCalls[0].arguments, { path: 'a.txt' });
271
+ assert.match(leakedCalls[0].id, /^gemini_leaked_/);
272
+ assert.equal(captured.length, 1);
273
+ assert.equal(captured[0].name, 'read');
274
+ });
275
+
276
+ test('gemini leak guard: unknown tool name → text flushed, no synthetic call', () => {
277
+ const leaked = '<function_calls>\n<invoke name="nonexistent_tool">\n<parameter name="x">1</parameter>\n</invoke>\n</function_calls>';
278
+ const captured = [];
279
+ const guard = createGeminiTextLeakGuard({
280
+ knownToolNames: LEAK_TOOLS,
281
+ onToolCall: (c) => captured.push(c),
282
+ });
283
+ guard.feedText(leaked);
284
+ guard.finalize();
285
+ const content = guard.scrubAssistantText(leaked);
286
+ assert.equal(guard.getLeakedToolCalls().length, 0);
287
+ assert.equal(captured.length, 0);
288
+ assert.ok(content.includes('nonexistent_tool'));
289
+ });
290
+
234
291
  // === 3. anthropic / anthropic-oauth ========================================
235
292
  // tool_use block parser lives in anthropic-oauth.mjs:936 parseSSEStream
236
293
  // (content_block_start/delta/stop → toolCalls.push). anthropic.mjs has NO
@@ -303,6 +360,109 @@ test('anthropic(-oauth): text-only stream → no toolCalls', async () => {
303
360
  assert.equal(result.content, 'hello');
304
361
  });
305
362
 
363
+ // --- Leaked tool-call recovery (shared parseSSEStream guard) ----------------
364
+ // The model sometimes emits a tool call as plain text tags inside text_delta
365
+ // instead of a native tool_use block. The guard (8th arg = known tool names)
366
+ // suppresses the tags from the visible stream, removes them from content, and
367
+ // synthesizes/dispatches a real tool call. anthropic.mjs reuses this SAME
368
+ // parseSSEStream, so both providers are covered by one guard.
369
+ const LEAK_TOOLS = new Set(['shell', 'read']);
370
+
371
+ function textDeltaEvents(chunks, stopReason = 'end_turn') {
372
+ return [
373
+ { type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
374
+ { type: 'content_block_start', index: 0, content_block: { type: 'text' } },
375
+ ...chunks.map((text) => ({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } })),
376
+ { type: 'content_block_stop', index: 0 },
377
+ { type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
378
+ { type: 'message_stop' },
379
+ ];
380
+ }
381
+
382
+ test('anthropic(-oauth) leak guard: leaked <function_calls>/<invoke> for known tool → recovered, no leak', async () => {
383
+ const leaked = 'Sure.\n<function_calls>\n<invoke name="shell">\n<parameter name="command">ls -la</parameter>\n</invoke>\n</function_calls>';
384
+ const texts = [];
385
+ const captured = [];
386
+ const result = await anthropicParseSSEStream(
387
+ anthropicSseResponse(textDeltaEvents([leaked])),
388
+ null, () => {}, () => {},
389
+ (call) => captured.push(call),
390
+ {},
391
+ (t) => texts.push(t),
392
+ LEAK_TOOLS,
393
+ );
394
+ const emitted = texts.join('');
395
+ // Tags never reached the visible stream nor the returned content.
396
+ assert.equal(/<invoke|<function_calls|<parameter/.test(emitted), false);
397
+ assert.equal(/<invoke|<function_calls|<parameter/.test(result.content), false);
398
+ assert.ok(emitted.includes('Sure.'));
399
+ assert.ok(result.content.includes('Sure.'));
400
+ // A real, dispatched tool call was synthesized.
401
+ assert.equal(result.toolCalls.length, 1);
402
+ assert.equal(result.toolCalls[0].name, 'shell');
403
+ assert.deepEqual(result.toolCalls[0].arguments, { command: 'ls -la' });
404
+ assert.equal(captured.length, 1);
405
+ assert.equal(captured[0].name, 'shell');
406
+ assert.deepEqual(captured[0].arguments, { command: 'ls -la' });
407
+ });
408
+
409
+ test('anthropic(-oauth) leak guard: tags split across two text_delta chunks still detected', async () => {
410
+ const a = '<invoke name="sh';
411
+ const b = 'ell">\n<parameter name="command">pwd</parameter>\n</invoke>';
412
+ const texts = [];
413
+ const captured = [];
414
+ const result = await anthropicParseSSEStream(
415
+ anthropicSseResponse(textDeltaEvents([a, b])),
416
+ null, () => {}, () => {},
417
+ (call) => captured.push(call),
418
+ {},
419
+ (t) => texts.push(t),
420
+ LEAK_TOOLS,
421
+ );
422
+ assert.equal(/<invoke|<parameter/.test(texts.join('')), false);
423
+ assert.equal(result.toolCalls.length, 1);
424
+ assert.equal(result.toolCalls[0].name, 'shell');
425
+ assert.deepEqual(result.toolCalls[0].arguments, { command: 'pwd' });
426
+ assert.deepEqual(captured[0].arguments, { command: 'pwd' });
427
+ });
428
+
429
+ test('anthropic(-oauth) leak guard: unknown tool name → text flushed, no synthetic call', async () => {
430
+ const leaked = '<function_calls>\n<invoke name="nonexistent_tool">\n<parameter name="x">1</parameter>\n</invoke>\n</function_calls>';
431
+ const texts = [];
432
+ const captured = [];
433
+ const result = await anthropicParseSSEStream(
434
+ anthropicSseResponse(textDeltaEvents([leaked])),
435
+ null, () => {}, () => {},
436
+ (call) => captured.push(call),
437
+ {},
438
+ (t) => texts.push(t),
439
+ LEAK_TOOLS,
440
+ );
441
+ // Not a known tool: nothing recovered, and the text is preserved (never lost).
442
+ assert.equal(result.toolCalls, undefined);
443
+ assert.equal(captured.length, 0);
444
+ assert.ok(texts.join('').includes('nonexistent_tool'));
445
+ assert.ok(result.content.includes('nonexistent_tool'));
446
+ });
447
+
448
+ test('anthropic(-oauth) leak guard: benign prose <function> mention preserved, not swallowed', async () => {
449
+ const prose = 'Use the <function> keyword in JavaScript to declare a function.';
450
+ const texts = [];
451
+ const captured = [];
452
+ const result = await anthropicParseSSEStream(
453
+ anthropicSseResponse(textDeltaEvents([prose])),
454
+ null, () => {}, () => {},
455
+ (call) => captured.push(call),
456
+ {},
457
+ (t) => texts.push(t),
458
+ LEAK_TOOLS,
459
+ );
460
+ assert.equal(result.toolCalls, undefined);
461
+ assert.equal(captured.length, 0);
462
+ assert.equal(texts.join(''), prose);
463
+ assert.equal(result.content, prose);
464
+ });
465
+
306
466
  // === 4. openai-oauth / openai-oauth-ws =====================================
307
467
  // openai-oauth (HTTP/SSE) and openai-oauth-ws (WebSocket) both consume the
308
468
  // Responses event stream inside large stateful stream loops, NOT a standalone
@@ -374,3 +534,441 @@ test('openai-oauth-ws (tool_search): valid args / object / empty preserved', ()
374
534
  // Its tool_call extraction therefore goes through the exact
375
535
  // parseToolCalls / parseResponsesToolCalls already asserted in block 1 — no
376
536
  // duplicate test. (Documented as shared in the report.)
537
+
538
+ // === 6. OpenAI leaked tool-call recovery ===================================
539
+ // The model sometimes emits a tool call as PLAIN TEXT (XML `<invoke>` family
540
+ // or gpt-oss harmony `<|channel|>...to=functions.NAME...<|call|>`) inside a
541
+ // text delta instead of a native structured tool_call. The stream guards
542
+ // suppress the tags from the visible stream, synthesize a native-shaped call
543
+ // (`call_leaked_*` id), and dispatch it via the same onToolCall path.
544
+ const OAI_LEAK_TOOLS = new Set(['shell', 'read']);
545
+
546
+ function chatCompletionStream(contentChunks) {
547
+ // Each chunk is an assistant text delta; ends with a stop finish_reason.
548
+ const events = contentChunks.map((text) => ({
549
+ choices: [{ delta: { content: text } }],
550
+ }));
551
+ events.push({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { total_tokens: 1 } });
552
+ return compatResponsesEventStream(events);
553
+ }
554
+
555
+ function responsesTextStream(textChunks) {
556
+ const events = textChunks.map((delta) => ({ type: 'response.output_text.delta', delta }));
557
+ events.push({ type: 'response.completed', response: { id: 'r1', model: 'gpt', status: 'completed', output: [] } });
558
+ return compatResponsesEventStream(events);
559
+ }
560
+
561
+ test('openai-compat (chat) leak guard: leaked <invoke> for known tool → recovered, no leak', async () => {
562
+ const leaked = 'Sure.\n<function_calls>\n<invoke name="shell">\n<parameter name="command">ls -la</parameter>\n</invoke>\n</function_calls>';
563
+ const texts = [];
564
+ const captured = [];
565
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream([leaked]), {
566
+ label: 'test',
567
+ parseToolCalls: compatParseToolCalls,
568
+ onToolCall: (call) => captured.push(call),
569
+ onTextDelta: (t) => texts.push(t),
570
+ knownToolNames: OAI_LEAK_TOOLS,
571
+ });
572
+ const emitted = texts.join('');
573
+ assert.equal(/<invoke|<function_calls|<parameter/.test(emitted), false);
574
+ assert.equal(/<invoke|<function_calls|<parameter/.test(out.content), false);
575
+ assert.ok(emitted.includes('Sure.'));
576
+ assert.ok(out.content.includes('Sure.'));
577
+ assert.equal(out.toolCalls.length, 1);
578
+ assert.equal(out.toolCalls[0].name, 'shell');
579
+ assert.deepEqual(out.toolCalls[0].arguments, { command: 'ls -la' });
580
+ assert.match(out.toolCalls[0].id, /^call_leaked_/);
581
+ assert.equal(captured.length, 1);
582
+ assert.equal(captured[0].name, 'shell');
583
+ assert.deepEqual(captured[0].arguments, { command: 'ls -la' });
584
+ });
585
+
586
+ test('openai-compat (chat) leak guard: leaked harmony <|channel|>...<|call|> for known tool → recovered', async () => {
587
+ const leaked = '<|channel|>commentary to=functions.read <|constrain|>json<|message|>{"path":"a.txt"}<|call|>';
588
+ const texts = [];
589
+ const captured = [];
590
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream(['ok ', leaked]), {
591
+ label: 'test',
592
+ parseToolCalls: compatParseToolCalls,
593
+ onToolCall: (call) => captured.push(call),
594
+ onTextDelta: (t) => texts.push(t),
595
+ knownToolNames: OAI_LEAK_TOOLS,
596
+ });
597
+ assert.equal(/<\|channel\|>|to=functions|<\|call\|>/.test(texts.join('')), false);
598
+ assert.equal(/<\|channel\|>|<\|call\|>/.test(out.content), false);
599
+ assert.equal(out.toolCalls.length, 1);
600
+ assert.equal(out.toolCalls[0].name, 'read');
601
+ assert.deepEqual(out.toolCalls[0].arguments, { path: 'a.txt' });
602
+ assert.equal(captured.length, 1);
603
+ assert.equal(captured[0].name, 'read');
604
+ });
605
+
606
+ test('openai-compat (chat) leak guard: XML tags split across two content deltas still detected', async () => {
607
+ const a = '<invoke name="sh';
608
+ const b = 'ell">\n<parameter name="command">pwd</parameter>\n</invoke>';
609
+ const texts = [];
610
+ const captured = [];
611
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream([a, b]), {
612
+ label: 'test',
613
+ parseToolCalls: compatParseToolCalls,
614
+ onToolCall: (call) => captured.push(call),
615
+ onTextDelta: (t) => texts.push(t),
616
+ knownToolNames: OAI_LEAK_TOOLS,
617
+ });
618
+ assert.equal(/<invoke|<parameter/.test(texts.join('')), false);
619
+ assert.equal(out.toolCalls.length, 1);
620
+ assert.equal(out.toolCalls[0].name, 'shell');
621
+ assert.deepEqual(out.toolCalls[0].arguments, { command: 'pwd' });
622
+ assert.deepEqual(captured[0].arguments, { command: 'pwd' });
623
+ });
624
+
625
+ test('openai-compat (chat) leak guard: unknown tool → text preserved, no synthetic call', async () => {
626
+ const leaked = '<function_calls>\n<invoke name="nonexistent_tool">\n<parameter name="x">1</parameter>\n</invoke>\n</function_calls>';
627
+ const texts = [];
628
+ const captured = [];
629
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream([leaked]), {
630
+ label: 'test',
631
+ parseToolCalls: compatParseToolCalls,
632
+ onToolCall: (call) => captured.push(call),
633
+ onTextDelta: (t) => texts.push(t),
634
+ knownToolNames: OAI_LEAK_TOOLS,
635
+ });
636
+ assert.equal(out.toolCalls, undefined);
637
+ assert.equal(captured.length, 0);
638
+ assert.ok(texts.join('').includes('nonexistent_tool'));
639
+ assert.ok(out.content.includes('nonexistent_tool'));
640
+ });
641
+
642
+ test('openai-compat (chat) leak guard: benign prose preserved, native tool_calls path intact', async () => {
643
+ const prose = 'Use the <function> keyword in JavaScript.';
644
+ const texts = [];
645
+ const captured = [];
646
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream([prose]), {
647
+ label: 'test',
648
+ parseToolCalls: compatParseToolCalls,
649
+ onToolCall: (call) => captured.push(call),
650
+ onTextDelta: (t) => texts.push(t),
651
+ knownToolNames: OAI_LEAK_TOOLS,
652
+ });
653
+ assert.equal(out.toolCalls, undefined);
654
+ assert.equal(captured.length, 0);
655
+ assert.equal(texts.join(''), prose);
656
+ assert.equal(out.content, prose);
657
+ });
658
+
659
+ test('openai-compat (chat) leak guard: native structured tool_calls still work with guard enabled', async () => {
660
+ const captured = [];
661
+ const events = [
662
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'read', arguments: '{"path":"x"}' } }] } }] },
663
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }], usage: { total_tokens: 1 } },
664
+ ];
665
+ const out = await consumeCompatChatCompletionStream(compatResponsesEventStream(events), {
666
+ label: 'test',
667
+ parseToolCalls: compatParseToolCalls,
668
+ onToolCall: (call) => captured.push(call),
669
+ onTextDelta: () => {},
670
+ knownToolNames: OAI_LEAK_TOOLS,
671
+ });
672
+ assert.deepEqual(out.toolCalls, [{ id: 'call_1', name: 'read', arguments: { path: 'x' } }]);
673
+ assert.deepEqual(captured, [{ id: 'call_1', name: 'read', arguments: { path: 'x' } }]);
674
+ });
675
+
676
+ test('openai-compat (responses) leak guard: leaked <invoke> in output_text.delta → recovered', async () => {
677
+ const leaked = 'Working.\n<invoke name="shell">\n<parameter name="command">whoami</parameter>\n</invoke>';
678
+ const texts = [];
679
+ const captured = [];
680
+ const out = await consumeCompatResponsesStream(responsesTextStream([leaked]), {
681
+ label: 'test',
682
+ parseResponsesToolCalls: compatParseResponsesToolCalls,
683
+ responseOutputText: () => '',
684
+ onToolCall: (call) => captured.push(call),
685
+ onTextDelta: (t) => texts.push(t),
686
+ knownToolNames: OAI_LEAK_TOOLS,
687
+ });
688
+ assert.equal(/<invoke|<parameter/.test(texts.join('')), false);
689
+ assert.equal(/<invoke|<parameter/.test(out.content), false);
690
+ assert.ok(out.content.includes('Working.'));
691
+ assert.equal(out.toolCalls.length, 1);
692
+ assert.equal(out.toolCalls[0].name, 'shell');
693
+ assert.deepEqual(out.toolCalls[0].arguments, { command: 'whoami' });
694
+ assert.match(out.toolCalls[0].id, /^call_leaked_/);
695
+ assert.equal(captured.length, 1);
696
+ });
697
+
698
+ test('openai-compat (responses) leak guard: harmony syntax split across two deltas → recovered', async () => {
699
+ const a = '<|channel|>commentary to=functions.read <|message|>{"path":';
700
+ const b = '"b.txt"}<|call|>';
701
+ const captured = [];
702
+ const out = await consumeCompatResponsesStream(responsesTextStream([a, b]), {
703
+ label: 'test',
704
+ parseResponsesToolCalls: compatParseResponsesToolCalls,
705
+ responseOutputText: () => '',
706
+ onToolCall: (call) => captured.push(call),
707
+ onTextDelta: () => {},
708
+ knownToolNames: OAI_LEAK_TOOLS,
709
+ });
710
+ assert.equal(out.toolCalls.length, 1);
711
+ assert.equal(out.toolCalls[0].name, 'read');
712
+ assert.deepEqual(out.toolCalls[0].arguments, { path: 'b.txt' });
713
+ assert.equal(captured.length, 1);
714
+ });
715
+
716
+ test('openai-compat (responses) leak guard: benign prose preserved, no synthetic call', async () => {
717
+ const prose = 'Just some prose about functions and channels.';
718
+ const captured = [];
719
+ const out = await consumeCompatResponsesStream(responsesTextStream([prose]), {
720
+ label: 'test',
721
+ parseResponsesToolCalls: compatParseResponsesToolCalls,
722
+ responseOutputText: () => '',
723
+ onToolCall: (call) => captured.push(call),
724
+ onTextDelta: () => {},
725
+ knownToolNames: OAI_LEAK_TOOLS,
726
+ });
727
+ assert.equal(out.toolCalls, undefined);
728
+ assert.equal(captured.length, 0);
729
+ assert.ok(out.content.includes('Just some prose'));
730
+ });
731
+
732
+ // === 7. Reviewer fixes: fence gating, cross-path dedupe, bare-antml ========
733
+
734
+ // --- Fix 1: code-fence / inline-code gating (Anthropic path) ---------------
735
+ // A complete <invoke> written inside a ```code fence``` or inline `code` span
736
+ // is a documentation example, not a real call: it must stream as visible text
737
+ // and NOT dispatch. The control (same tag OUTSIDE a fence) still recovers.
738
+ test('anthropic leak guard (fence): <invoke> inside a fenced code block → emitted as text, NOT dispatched', async () => {
739
+ const fenced = 'Example:\n```\n<invoke name="read"><parameter name="path">a</parameter></invoke>\n```\ndone';
740
+ const texts = [];
741
+ const captured = [];
742
+ const result = await anthropicParseSSEStream(
743
+ anthropicSseResponse(textDeltaEvents([fenced])),
744
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
745
+ );
746
+ assert.equal(result.toolCalls, undefined);
747
+ assert.equal(captured.length, 0);
748
+ assert.ok(texts.join('').includes('<invoke name="read">'));
749
+ assert.ok(result.content.includes('<invoke name="read">'));
750
+ });
751
+
752
+ test('anthropic leak guard (fence): fence OPENS in one delta and CLOSES in a later one, tag between → NOT dispatched', async () => {
753
+ const chunks = ['Here is an example:\n```json\n', '<invoke name="read"><parameter name="path">a</parameter></invoke>\n', '```\nEnd.'];
754
+ const texts = [];
755
+ const captured = [];
756
+ const result = await anthropicParseSSEStream(
757
+ anthropicSseResponse(textDeltaEvents(chunks)),
758
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
759
+ );
760
+ assert.equal(result.toolCalls, undefined);
761
+ assert.equal(captured.length, 0);
762
+ assert.ok(texts.join('').includes('<invoke name="read">'));
763
+ });
764
+
765
+ test('anthropic leak guard (fence): <invoke> inside an inline `code` span → NOT dispatched', async () => {
766
+ const inline = 'Call it like `<invoke name="read"><parameter name="path">a</parameter></invoke>` in the docs.';
767
+ const texts = [];
768
+ const captured = [];
769
+ const result = await anthropicParseSSEStream(
770
+ anthropicSseResponse(textDeltaEvents([inline])),
771
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
772
+ );
773
+ assert.equal(result.toolCalls, undefined);
774
+ assert.equal(captured.length, 0);
775
+ assert.ok(texts.join('').includes('<invoke name="read">'));
776
+ });
777
+
778
+ test('anthropic leak guard (fence CONTROL): same <invoke> OUTSIDE any fence → still recovered/dispatched', async () => {
779
+ const outside = 'Sure.\n<invoke name="read"><parameter name="path">a</parameter></invoke>';
780
+ const texts = [];
781
+ const captured = [];
782
+ const result = await anthropicParseSSEStream(
783
+ anthropicSseResponse(textDeltaEvents([outside])),
784
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
785
+ );
786
+ assert.equal(/<invoke|<parameter/.test(texts.join('')), false);
787
+ assert.equal(result.toolCalls.length, 1);
788
+ assert.equal(result.toolCalls[0].name, 'read');
789
+ assert.deepEqual(result.toolCalls[0].arguments, { path: 'a' });
790
+ assert.equal(captured.length, 1);
791
+ });
792
+
793
+ // --- Fix 1: harmony example inside a fence (OpenAI chat path) → NOT dispatched
794
+ test('openai-compat (chat) leak guard (fence): harmony example inside a ```fence``` → emitted as text, NOT dispatched', async () => {
795
+ const fenced = 'Doc:\n```\n<|channel|>commentary to=functions.read <|message|>{"path":"a"}<|call|>\n```\n';
796
+ const texts = [];
797
+ const captured = [];
798
+ const out = await consumeCompatChatCompletionStream(chatCompletionStream([fenced]), {
799
+ label: 'test',
800
+ parseToolCalls: compatParseToolCalls,
801
+ onToolCall: (call) => captured.push(call),
802
+ onTextDelta: (t) => texts.push(t),
803
+ knownToolNames: OAI_LEAK_TOOLS,
804
+ });
805
+ assert.equal(out.toolCalls, undefined);
806
+ assert.equal(captured.length, 0);
807
+ assert.ok(texts.join('').includes('to=functions.read'));
808
+ });
809
+
810
+ // --- Fix 2: duplicate dispatch (text-leaked + identical native) → ONE fire --
811
+ test('anthropic leak guard (dedupe): text-leaked call + identical native tool_use → onToolCall fires ONCE', async () => {
812
+ const leaked = '<invoke name="shell"><parameter name="command">ls</parameter></invoke>';
813
+ const events = [
814
+ { type: 'message_start', message: { model: 'claude', usage: { input_tokens: 1 } } },
815
+ { type: 'content_block_start', index: 0, content_block: { type: 'text' } },
816
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: leaked } },
817
+ { type: 'content_block_stop', index: 0 },
818
+ { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_1', name: 'shell' } },
819
+ { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"command":"ls"}' } },
820
+ { type: 'content_block_stop', index: 1 },
821
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 1 } },
822
+ { type: 'message_stop' },
823
+ ];
824
+ const captured = [];
825
+ const result = await anthropicParseSSEStream(
826
+ anthropicSseResponse(events),
827
+ null, () => {}, () => {}, (c) => captured.push(c), {}, () => {}, LEAK_TOOLS,
828
+ );
829
+ // Exactly one dispatch for the identical (name,args) fingerprint.
830
+ assert.equal(captured.length, 1);
831
+ assert.equal(captured[0].name, 'shell');
832
+ assert.deepEqual(captured[0].arguments, { command: 'ls' });
833
+ // ...and the RETURNED array carries exactly one — else the loop would
834
+ // execute the side-effecting tool twice (Fix 2 array side).
835
+ assert.equal(result.toolCalls.length, 1);
836
+ });
837
+
838
+ test('openai-compat (chat) leak guard (dedupe): text-leaked call + identical native tool_calls → onToolCall fires ONCE', async () => {
839
+ const leaked = '<invoke name="read"><parameter name="path">a</parameter></invoke>';
840
+ const events = [
841
+ { choices: [{ delta: { content: leaked } }] },
842
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'read', arguments: '{"path":"a"}' } }] } }] },
843
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }], usage: { total_tokens: 1 } },
844
+ ];
845
+ const captured = [];
846
+ const out = await consumeCompatChatCompletionStream(compatResponsesEventStream(events), {
847
+ label: 'test',
848
+ parseToolCalls: compatParseToolCalls,
849
+ onToolCall: (call) => captured.push(call),
850
+ onTextDelta: () => {},
851
+ knownToolNames: OAI_LEAK_TOOLS,
852
+ });
853
+ assert.equal(captured.length, 1);
854
+ assert.equal(captured[0].name, 'read');
855
+ assert.deepEqual(captured[0].arguments, { path: 'a' });
856
+ // The RETURNED array is deduped too (Fix 2 array side): the agent loop
857
+ // executes returned toolCalls, so a synthetic+native duplicate here would
858
+ // run the side-effecting tool twice. Exactly one must survive.
859
+ assert.equal(out.toolCalls.length, 1);
860
+ assert.equal(out.toolCalls[0].name, 'read');
861
+ });
862
+
863
+ // --- Fix 3: bare `antml:invoke` in prose (no `<`) → streamed, not held ------
864
+ test('anthropic leak guard (bare-antml): literal "antml:invoke" in prose → streamed promptly, no dispatch', async () => {
865
+ const prose = 'The tag antml:invoke is used internally; here we just mention it in a sentence.';
866
+ const texts = [];
867
+ const captured = [];
868
+ const result = await anthropicParseSSEStream(
869
+ anthropicSseResponse(textDeltaEvents([prose])),
870
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
871
+ );
872
+ assert.equal(result.toolCalls, undefined);
873
+ assert.equal(captured.length, 0);
874
+ // Streamed promptly (not held to final) and content intact.
875
+ assert.equal(texts.join(''), prose);
876
+ assert.equal(result.content, prose);
877
+ });
878
+
879
+ test('anthropic leak guard (bare-antml CONTROL): <invoke> bracket form still recovers', async () => {
880
+ const leaked = 'ok\n<invoke name="read"><parameter name="path">z</parameter></invoke>';
881
+ const captured = [];
882
+ const result = await anthropicParseSSEStream(
883
+ anthropicSseResponse(textDeltaEvents([leaked])),
884
+ null, () => {}, () => {}, (c) => captured.push(c), {}, () => {}, LEAK_TOOLS,
885
+ );
886
+ assert.equal(result.toolCalls.length, 1);
887
+ assert.equal(captured.length, 1);
888
+ assert.equal(captured[0].name, 'read');
889
+ assert.deepEqual(captured[0].arguments, { path: 'z' });
890
+ });
891
+
892
+ // --- Reviewer Medium: suppressed call args must NOT poison fence state -------
893
+ // A recovered/suppressed leaked call whose args contain an unmatched backtick
894
+ // must not leave the markdown fence "open" and wrongly suppress a LATER real
895
+ // leaked call as if it were inside a code span.
896
+ test('anthropic leak guard (fence): unmatched backtick inside a suppressed call\'s args does not swallow a later real call', async () => {
897
+ // First leaked call carries a lone backtick in its arg value; second is a
898
+ // clean leaked call that MUST still be recovered.
899
+ const first = '<invoke name="read"><parameter name="path">a`b</parameter></invoke>';
900
+ const between = ' some visible prose ';
901
+ const second = '<invoke name="shell"><parameter name="command">ls</parameter></invoke>';
902
+ const texts = [];
903
+ const captured = [];
904
+ const result = await anthropicParseSSEStream(
905
+ anthropicSseResponse(textDeltaEvents([first + between + second])),
906
+ null, () => {}, () => {}, (c) => captured.push(c), {}, (t) => texts.push(t), LEAK_TOOLS,
907
+ );
908
+ // BOTH leaked calls recovered — the stray backtick in call #1's args did
909
+ // not open a fence that suppressed call #2.
910
+ assert.equal(captured.length, 2);
911
+ assert.equal(result.toolCalls.length, 2);
912
+ assert.deepEqual(captured.map((c) => c.name), ['read', 'shell']);
913
+ // The in-between prose streamed; the tag/arg text did not leak.
914
+ assert.equal(texts.join('').includes('some visible prose'), true);
915
+ assert.equal(texts.join('').includes('<invoke'), false);
916
+ assert.equal(texts.join('').includes('`'), false);
917
+ });
918
+
919
+ // === 8. Anthropic effort (output_config vs legacy thinking budget) ==========
920
+
921
+ test('anthropic effort: sonnet-4-6 uses output_config + effort beta, not thinking', () => {
922
+ const model = 'claude-sonnet-4-6';
923
+ const body = _buildRequestBodyForCacheSmoke(
924
+ [{ role: 'user', content: 'hi' }],
925
+ model,
926
+ [],
927
+ { effort: 'high' },
928
+ );
929
+ assert.deepEqual(body.output_config, { effort: 'high' });
930
+ assert.equal(body.thinking, undefined);
931
+ assert.equal(shouldIncludeEffortBeta(model, { effort: 'high' }), true);
932
+ const beta = buildAnthropicBetaHeaders({ effort: true });
933
+ assert.ok(beta.includes(EFFORT_BETA_HEADER));
934
+ });
935
+
936
+ test('anthropic effort: legacy sonnet-4-5 maps effort to thinking budget', () => {
937
+ const model = 'claude-sonnet-4-5-20250514';
938
+ const body = _buildRequestBodyForCacheSmoke(
939
+ [{ role: 'user', content: 'hi' }],
940
+ model,
941
+ [],
942
+ { effort: 'medium' },
943
+ );
944
+ assert.equal(body.output_config, undefined);
945
+ assert.deepEqual(body.thinking, { type: 'enabled', budget_tokens: LEGACY_EFFORT_BUDGET.medium });
946
+ assert.equal(modelSupportsEffort(model), false);
947
+ });
948
+
949
+ test('anthropic effort: xhigh on opus-4-8 normalizes to max in output_config', () => {
950
+ const model = 'claude-opus-4-8';
951
+ assert.equal(modelSupportsMaxEffort(model), true);
952
+ assert.equal(normalizeAnthropicEffortInput('xhigh', model), 'max');
953
+ const body = _buildRequestBodyForCacheSmoke(
954
+ [{ role: 'user', content: 'hi' }],
955
+ model,
956
+ [],
957
+ { effort: 'xhigh' },
958
+ );
959
+ assert.deepEqual(body.output_config, { effort: 'max' });
960
+ assert.equal(body.thinking, undefined);
961
+ });
962
+
963
+ test('anthropic effort: explicit thinkingBudgetTokens wins over effort', () => {
964
+ const model = 'claude-sonnet-4-6';
965
+ const body = _buildRequestBodyForCacheSmoke(
966
+ [{ role: 'user', content: 'hi' }],
967
+ model,
968
+ [],
969
+ { effort: 'low', thinkingBudgetTokens: 2048 },
970
+ );
971
+ assert.deepEqual(body.thinking, { type: 'enabled', budget_tokens: 2048 });
972
+ assert.equal(body.output_config, undefined);
973
+ assert.equal(shouldIncludeEffortBeta(model, { effort: 'low', thinkingBudgetTokens: 2048 }), false);
974
+ });