Repository navigation
fix: clear nativeArgs when tool-call finalize fails (#1221) #1634
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f99422e
79b28e7
02286d8
d5ba3bd
08b4082
5e9136b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -33,6 +33,7 @@ import type { ApiMessage } from "../../task-persistence" | |||||||||
| import { asyncStreamFrom } from "../../../test-utils/stream" | ||||||||||
| import { McpHub } from "../../../services/mcp/McpHub" | ||||||||||
| import { McpServerManager } from "../../../services/mcp/McpServerManager" | ||||||||||
| import { writeToFileTool } from "../../tools/WriteToFileTool" | ||||||||||
|
|
||||||||||
| type TaskTestAccess = { | ||||||||||
| getSystemPrompt: (requestState: ProviderState | undefined, requestModelInfo?: ModelInfo) => Promise<string> | ||||||||||
|
|
@@ -750,6 +751,102 @@ describe("Cline", () => { | |||||||||
| }, | ||||||||||
| ]) | ||||||||||
| }) | ||||||||||
|
|
||||||||||
| it("blocks a truncated write_to_file call instead of executing it (issue #1221)", async () => { | ||||||||||
| // Regression test for #1221: if the model's stream is cut off mid-way | ||||||||||
| // through a write_to_file tool call's `content` argument (e.g. it hits | ||||||||||
| // max_tokens), finalizeStreamingToolCall() can't parse the incomplete | ||||||||||
| // JSON and returns null. Task.ts must not let the truncated content | ||||||||||
| // reach writeToFileTool's execution path - it must clear nativeArgs so | ||||||||||
| // presentAssistantMessage's fail-closed guard emits a structured | ||||||||||
| // tool_result error instead. | ||||||||||
| // | ||||||||||
| // Unlike the simulation-based tests in truncated-native-tool-args.spec.ts, | ||||||||||
| // this drives the real streaming + presentAssistantMessage flow through | ||||||||||
| // Task, and spies on the actual tool handler to prove it is never invoked. | ||||||||||
| const task = new Task({ | ||||||||||
| provider: mockProvider, | ||||||||||
| apiConfiguration: mockApiConfig, | ||||||||||
| task: "truncated tool call test", | ||||||||||
| startTask: false, | ||||||||||
| }) | ||||||||||
|
|
||||||||||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||||||||||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||||||||||
| // presentAssistantMessageSafe is intentionally left un-mocked here (unlike | ||||||||||
| // the other tests in this block) - the whole point is to exercise the real | ||||||||||
| // dispatch/guard logic, not just tool_use finalization. | ||||||||||
|
|
||||||||||
| const writeToFileHandleSpy = vi.spyOn(writeToFileTool, "handle") | ||||||||||
| // Spy directly on the guard's own push, rather than inspecting | ||||||||||
| // userMessageContent/apiConversationHistory afterwards - the task | ||||||||||
| // recurses into a follow-up request once the tool_result is ready | ||||||||||
| // (see the second mocked stream below), which resets those arrays | ||||||||||
| // for the new turn before this function returns. | ||||||||||
| const pushToolResultSpy = vi.spyOn(task, "pushToolResultToUserContent") | ||||||||||
|
|
||||||||||
| vi.spyOn(task, "attemptApiRequest") | ||||||||||
| .mockImplementationOnce(() => | ||||||||||
| asyncStreamFrom<ApiStreamChunk>([ | ||||||||||
| { | ||||||||||
| type: "tool_call_partial", | ||||||||||
| index: 0, | ||||||||||
| id: "call_truncated", | ||||||||||
| name: "write_to_file", | ||||||||||
| }, | ||||||||||
| { | ||||||||||
| type: "tool_call_partial", | ||||||||||
| index: 0, | ||||||||||
| // Cut off mid-string: no closing quote/brace, and the stream | ||||||||||
| // ends here with no explicit tool_call_end - exactly what | ||||||||||
| // happens when the model hits max_tokens mid-argument. | ||||||||||
| // .md path deliberately used so the only thing that can block | ||||||||||
| // execution is the nativeArgs guard under test - an arbitrary | ||||||||||
| // extension could also get caught by unrelated mode-based file | ||||||||||
| // restrictions (e.g. Architect mode's markdown-only rule), | ||||||||||
| // which would produce a false pass/fail unrelated to this bug. | ||||||||||
| arguments: '{"path":"docs/config.md","content":"sk-live-abc123', | ||||||||||
| }, | ||||||||||
| ]), | ||||||||||
| ) | ||||||||||
| // The task recurses once the error tool_result makes the turn | ||||||||||
| // "ready" - this bounds that follow-up to a single harmless text | ||||||||||
| // reply instead of an unmocked second call. | ||||||||||
| .mockImplementationOnce(() => asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "" }])) | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '750,855p' src/core/task/__tests__/Task.spec.ts
rg -n 'empty response|emptyResponse|retry.*empty|no assistant' src/core/task/Task.tsRepository: Zoo-Code-Org/Zoo-Code Length of output: 5543 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- Task control-flow symbols ---'
rg -n -C 8 'recursivelyMakeClineRequests|attemptApiRequest|assistant_responses|no assistant messages|asyncStreamFrom|throw new Error' src/core/task/Task.ts src/core/task/__tests__/Task.spec.ts src -g '*.ts' -g '*.tsx' | head -n 1000
printf '%s\n' '--- Task empty-response region ---'
sed -n '4030,4235p' src/core/task/Task.ts
printf '%s\n' '--- Task request entry region ---'
rg -n 'async recursivelyMakeClineRequests|recursivelyMakeClineRequests\(' src/core/task/Task.ts
sed -n '2860,3060p' src/core/task/Task.ts
printf '%s\n' '--- asyncStreamFrom definition and nearby test setup ---'
rg -n 'function asyncStreamFrom|const asyncStreamFrom|asyncStreamFrom' src/core/task/__tests__ src -g '*.ts' -g '*.tsx' | head -n 200
sed -n '1,180p' src/core/task/__tests__/Task.spec.tsRepository: Zoo-Code-Org/Zoo-Code Length of output: 42160 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- recursivelyMakeClineRequests core ---'
sed -n '2979,3385p' src/core/task/Task.ts
printf '%s\n' '--- empty-response branch ---'
sed -n '4070,4220p' src/core/task/Task.ts
printf '%s\n' '--- stream helper ---'
rg -n 'export (async )?function asyncStreamFrom|function asyncStreamFrom|const asyncStreamFrom' src/test-utils src -g '*.ts' -g '*.tsx'
sed -n '1,140p' src/test-utils/stream.ts
printf '%s\n' '--- relevant test setup and ask mocks ---'
rg -n -C 5 'describe\\(|mock.*ask|auto.*approv|ask\\(|consecutiveNoAssistant|recursivelyMakeClineRequests|attemptApiRequest' src/core/task/__tests__/Task.spec.ts | tail -n 500Repository: Zoo-Code-Org/Zoo-Code Length of output: 22555 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- remainder of empty-response branch ---'
sed -n '4180,4270p' src/core/task/Task.ts
printf '%s\n' '--- stream helper candidates ---'
git ls-files | rg '(^|/)stream\.(ts|tsx|js|mjs)$|test-utils'
rg -n -C 4 'asyncStreamFrom' --glob '*.ts' --glob '*.tsx' . | head -n 160
printf '%s\n' '--- test setup around regression and ask/provider mocks ---'
sed -n '680,840p' src/core/task/__tests__/Task.spec.ts
rg -n -C 6 'mock.*ask|\\.ask|autoApprovalEnabled|mockResolvedValue.*messageResponse|yesButtonClicked|api_req_failed' src/core/task/__tests__/Task.spec.tsRepository: Zoo-Code-Org/Zoo-Code Length of output: 44679 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- shared Task.spec.ts setup ---'
sed -n '330,485p' src/core/task/__tests__/Task.spec.ts
printf '%s\n' '--- ask-related setup before regression ---'
rg -n -C 3 'spyOn\\([^)]*ask|mock.*ask|ask\\s*=|autoApprovalEnabled' src/core/task/__tests__/Task.spec.ts | head -n 220
printf '%s\n' '--- stream helper body ---'
cat -n src/test-utils/stream.ts | sed -n '1,100p'Repository: Zoo-Code-Org/Zoo-Code Length of output: 5409 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- Task.ask implementation ---'
rg -n 'async ask\\(|public async ask\\(|private async ask\\(' src/core/task/Task.ts
sed -n '1410,1710p' src/core/task/Task.ts
printf '%s\n' '--- autoApprovalEnabled defaults and state declarations ---'
rg -n -C 5 'autoApprovalEnabled' src/core/webview/ClineProvider.ts src/core src -g '*.ts' | head -n 260Repository: Zoo-Code-Org/Zoo-Code Length of output: 31032 Stop the test after the follow-up request. The empty second stream enters the empty-response branch. With auto-approval disabled by default, Suggested fix- .mockImplementationOnce(() => asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "" }]))
+ .mockImplementationOnce(() => {
+ throw new Error("stop after truncated-tool regression response")
+ })📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| await task.recursivelyMakeClineRequests([{ type: "text", text: "truncated tool call test" }]) | ||||||||||
|
|
||||||||||
| // handle() legitimately gets called with partial: true while the call is | ||||||||||
| // still streaming (BaseTool.handle short-circuits to a no-op preview hook | ||||||||||
| // in that case) - that's expected and safe. What must never happen is a | ||||||||||
| // call with partial: false, which is what actually reaches execute() and | ||||||||||
| // writes to disk. | ||||||||||
| const nonPartialCalls = writeToFileHandleSpy.mock.calls.filter( | ||||||||||
| ([, block]) => (block as { partial?: boolean }).partial === false, | ||||||||||
| ) | ||||||||||
| expect(nonPartialCalls).toHaveLength(0) | ||||||||||
|
|
||||||||||
| // Neither nativeArgs nor params should leak the truncated content into | ||||||||||
| // the recorded assistant turn - Task.ts builds that entry's `input` via | ||||||||||
| // `toolUse.nativeArgs || toolUse.params`, so clearing nativeArgs alone | ||||||||||
| // would just have shifted the leak to params instead of closing it. | ||||||||||
| const assistantEntry = task.apiConversationHistory.find( | ||||||||||
| (m) => m.role === "assistant" && Array.isArray(m.content) && m.content[0]?.type === "tool_use", | ||||||||||
| ) | ||||||||||
| expect(JSON.stringify(assistantEntry)).not.toContain("sk-live-abc123") | ||||||||||
|
|
||||||||||
| // A structured, matching tool_result error must have been pushed for | ||||||||||
| // the truncated call's ID instead of letting it execute. | ||||||||||
| const truncatedCallResult = pushToolResultSpy.mock.calls.find( | ||||||||||
| ([result]) => result.tool_use_id === "call_truncated", | ||||||||||
| )?.[0] | ||||||||||
| expect(truncatedCallResult).toMatchObject({ | ||||||||||
| type: "tool_result", | ||||||||||
| tool_use_id: "call_truncated", | ||||||||||
| is_error: true, | ||||||||||
| }) | ||||||||||
| expect(JSON.stringify(truncatedCallResult)).toContain("missing nativeArgs") | ||||||||||
| }) | ||||||||||
| }) | ||||||||||
|
|
||||||||||
| describe("constructor", () => { | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** | ||
| * Regression test for issue #1221: truncated tool-call arguments can be silently | ||
| * written to disk. | ||
| * | ||
| * When a streamed native tool call's arguments are cut off mid-value (e.g. the | ||
| * model hits max_tokens while still writing write_to_file's `content` string), | ||
| * NativeToolCallParser.finalizeStreamingToolCall() returns null. Task.ts | ||
| * (~line 3748) reuses the same tool-use object the streaming phase was mutating | ||
| * in place and only sets `partial = false` - before the fix it left `nativeArgs` | ||
| * (built from the incomplete partial parse) untouched. | ||
| * | ||
| * presentAssistantMessage.ts (~line 443) is supposed to short-circuit exactly | ||
| * this case with a structured tool_result instead of executing the tool - but | ||
| * its guard is `isKnownTool && !block.nativeArgs && !customTool`. With | ||
| * nativeArgs still populated, the guard never fired and the truncated content | ||
| * would be passed straight to write_to_file's execution path. | ||
| * | ||
| * The fix clears `existingToolUse.nativeArgs` alongside `partial = false` at | ||
| * the finalize-null site, so the pre-existing guard actually does what its own | ||
| * comment already claimed. | ||
| */ | ||
|
|
||
| import { isValidToolName } from "../../tools/validateToolUse" | ||
| import type { ToolUse, WriteToFileToolUse } from "../../../shared/tools" | ||
|
|
||
| describe("Truncated native tool-call args on finalize failure (issue #1221)", () => { | ||
| /** | ||
| * Simulates the finalize-null branch from Task.ts (~line 3748) as it exists | ||
| * after the fix: on finalizeStreamingToolCall() returning null, mark the | ||
| * tool non-partial and clear nativeArgs. | ||
| */ | ||
| function finalizeNullBranch(existingToolUse: ToolUse): ToolUse { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason to keep these hand-written copies of the finalize-null branch and guard, given they can drift from the real logic that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought about pulling this file honestly. The drift risk is real, but I kept both - the Task.spec.ts test is the one that actually proves the fix works (drove it through a fix revert to confirm it fails for the right reason), so it'd catch drift in the finalize-null branch even if this file went stale. This one's just cheap and pins the exact guard condition down precisely, which is handy if someone's trying to understand what the bug was without reading through a mocked stream setup. Not attached to it though - if you'd rather it go, say so and I'll pull it.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm fine with leaving it, I think it's ok to have it here in case the other spec changes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for taking a look. |
||
| existingToolUse.partial = false | ||
| existingToolUse.nativeArgs = undefined | ||
| return existingToolUse | ||
|
Comment on lines
+32
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Test the production finalization path.
Drive a truncated 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Simulates the finalize-null branch as it existed *before* the fix, for a | ||
| * companion test proving the old behavior really was the bug (not just an | ||
| * assumption). | ||
| */ | ||
| function finalizeNullBranchBeforeFix(existingToolUse: ToolUse): ToolUse { | ||
| existingToolUse.partial = false | ||
| return existingToolUse | ||
| } | ||
|
|
||
| /** | ||
| * Simulates the short-circuit guard from presentAssistantMessage.ts (~line | ||
| * 443): `isKnownTool && !block.nativeArgs && !customTool`. Returns true when | ||
| * the tool call would be blocked (a structured tool_result emitted, no | ||
| * execution), false when it would proceed to execution. | ||
| */ | ||
| function wouldBeBlocked(block: ToolUse, customTool: unknown = undefined): boolean { | ||
| const isKnownTool = isValidToolName(String(block.name)) | ||
| return Boolean(isKnownTool && !block.nativeArgs && !customTool) | ||
| } | ||
|
|
||
| it("clears nativeArgs so a truncated write_to_file call is blocked instead of executed", () => { | ||
| // A write_to_file call whose `content` was cut off mid-stream - exactly | ||
| // the scenario in #1221. The streaming phase already populated nativeArgs | ||
| // from the incomplete partial-json parse before finalize failed. | ||
| const truncated: WriteToFileToolUse = { | ||
| type: "tool_use", | ||
| name: "write_to_file", | ||
| params: {}, | ||
| partial: true, | ||
| nativeArgs: { path: "src/config.json", content: '{"apiKey": "sk-live-abc123' /* cut off mid-string */ }, | ||
| } | ||
|
|
||
| finalizeNullBranch(truncated) | ||
|
|
||
| expect(truncated.partial).toBe(false) | ||
| expect(truncated.nativeArgs).toBeUndefined() | ||
| expect(wouldBeBlocked(truncated)).toBe(true) | ||
| }) | ||
|
|
||
| it("companion: without the fix, the same truncated call would NOT have been blocked", () => { | ||
| const truncated: WriteToFileToolUse = { | ||
| type: "tool_use", | ||
| name: "write_to_file", | ||
| params: {}, | ||
| partial: true, | ||
| nativeArgs: { path: "src/config.json", content: '{"apiKey": "sk-live-abc123' }, | ||
| } | ||
|
|
||
| finalizeNullBranchBeforeFix(truncated) | ||
|
|
||
| // This is the bug: partial is false (presented as "complete"), but | ||
| // nativeArgs still carries the truncated value, so the guard's | ||
| // `!block.nativeArgs` never becomes true and the call would proceed to | ||
| // execution with the truncated content. | ||
| expect(truncated.partial).toBe(false) | ||
| expect(truncated.nativeArgs).toEqual({ path: "src/config.json", content: '{"apiKey": "sk-live-abc123' }) | ||
| expect(wouldBeBlocked(truncated)).toBe(false) | ||
| }) | ||
|
|
||
| it("does not affect a normally-finalized (non-null) tool call", () => { | ||
| // When finalizeStreamingToolCall() succeeds, Task.ts replaces the block | ||
| // with the freshly-finalized one instead of taking this branch at all - | ||
| // this test just confirms a complete, valid nativeArgs is never touched | ||
| // by wouldBeBlocked's guard simulation. | ||
| const complete: WriteToFileToolUse = { | ||
| type: "tool_use", | ||
| name: "write_to_file", | ||
| params: {}, | ||
| partial: false, | ||
| nativeArgs: { path: "src/config.json", content: '{"apiKey": "sk-live-abc123xyz"}' }, | ||
| } | ||
|
|
||
| expect(wouldBeBlocked(complete)).toBe(false) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it also be worth clearing
paramshere, since the streaming partial parse still leaves the truncated values inparamsand they get echoed into API history via thetoolUse.nativeArgs || toolUse.paramsfallback?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, and you're actually right about the mechanism - I'd assumed params stayed {} for native calls based on how it's built at finalize time (Task.ts:713 area), but missed that the streaming partial-update path (NativeToolCallParser.ts:391) populates it too, for handlePartial's UI hooks.
Checked whether that made it an execution risk though: BaseTool.handle only ever reads nativeArgs to build execute()'s params, and throws instead of falling back to block.params when nativeArgs is undefined - so it was never actually exploitable. The real effect was just that the truncated content kept ending up in conversation history via the nativeArgs || params fallback, under a different name than before.
Cleared params to {} too in the latest commit and added an assertion that the recorded history entry for a truncated call doesn't contain the leaked content.