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.
- package/package.json +10 -3
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/session-ingest-smoke.mjs +2 -2
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/mixdog-debug.cjs +0 -22
- package/src/lib/plugin-paths.cjs +1 -7
- package/src/lib/rules-builder.cjs +34 -56
- package/src/mixdog-session-runtime.mjs +710 -319
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -4
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1254
- package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
- package/src/runtime/channels/lib/config.mjs +54 -3
- package/src/runtime/channels/lib/drop-trace.mjs +1 -1
- package/src/runtime/channels/lib/executor.mjs +0 -3
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/memory-client.mjs +0 -38
- package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/session-discovery.mjs +0 -4
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -1
- package/src/runtime/memory/data/runtime-manifest.json +6 -7
- package/src/runtime/memory/index.mjs +187 -43
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
- package/src/runtime/memory/lib/session-ingest.mjs +116 -7
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/search/index.mjs +2 -7
- package/src/runtime/search/lib/config.mjs +0 -4
- package/src/runtime/search/lib/state.mjs +1 -15
- package/src/runtime/search/lib/web-tools.mjs +0 -1
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/child-spawn-gate.mjs +0 -6
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/seeds.mjs +1 -11
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2137 -750
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +146 -9
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +177 -100
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +312 -40
- package/src/tui/components/tool-output-format.test.mjs +180 -1
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +7324 -2393
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +354 -142
- package/src/tui/markdown/format-token.test.mjs +155 -17
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -647
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +154 -20
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/hooks/lib/permission-rules.cjs +0 -170
- package/src/hooks/lib/settings-loader.cjs +0 -112
- package/src/lib/hook-pipe-path.cjs +0 -10
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
- package/src/workflows/default/workflow.json +0 -13
- 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 {
|
|
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
|
+
});
|