-
Notifications
You must be signed in to change notification settings - Fork 235
Fix tool_result order
#1260
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
base: main
Are you sure you want to change the base?
Fix tool_result order
#1260
Changes from all commits
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 |
|---|---|---|
|
|
@@ -132,7 +132,7 @@ import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHist | |
| import { MessageQueueService } from "../message-queue/MessageQueueService" | ||
| import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval" | ||
| import { MessageManager } from "../message-manager" | ||
| import { validateAndFixToolResultIds } from "./validateToolResultIds" | ||
| import { hoistToolResultsToFront, validateAndFixToolResultIds } from "./validateToolResultIds" | ||
| import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" | ||
| import { prepareApiConversationMessage } from "./apiConversationHistory" | ||
| import { shouldAddUserMessageToHistory } from "./messageCounting" | ||
|
|
@@ -4668,9 +4668,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike { | |
|
|
||
| // Default path for regular messages (no embedded reasoning) | ||
| if (msg.role) { | ||
| const content = | ||
| msg.role === "user" && Array.isArray(msg.content) | ||
| ? // Fix already-broken persisted tasks affected by bug | ||
| // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1259 | ||
| // that were created before the `hoistToolResultsToFront()` fix was introduced, | ||
| // by calling it here. | ||
| hoistToolResultsToFront(msg.content as Anthropic.Messages.ContentBlockParam[]) | ||
|
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. The helper tests do not exercise this persisted-history recovery hook, so they would still pass if request construction stopped normalizing old tasks. Can we add a task-level test that verifies |
||
| : (msg.content as Anthropic.Messages.ContentBlockParam[] | string) | ||
|
|
||
| cleanConversationHistory.push({ | ||
| role: msg.role, | ||
| content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, | ||
| content, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" | |
| import { TelemetryService } from "@roo-code/telemetry" | ||
| import { | ||
| validateAndFixToolResultIds, | ||
| hoistToolResultsToFront, | ||
| ToolResultIdMismatchError, | ||
| MissingToolResultError, | ||
| } from "../validateToolResultIds" | ||
|
|
@@ -994,4 +995,187 @@ describe("validateAndFixToolResultIds", () => { | |
| expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() | ||
| }) | ||
| }) | ||
|
|
||
| // Anthropic requires that tool_result blocks come FIRST in the user message content array; | ||
| // see the doc comment on `hoistToolResultsToFront()`. | ||
| describe("when tool_results are interleaved with other block types", () => { | ||
| it("should hoist a tool_result that follows an image block from a parallel tool call", () => { | ||
| const assistantMessage: Anthropic.MessageParam = { | ||
| role: "assistant", | ||
| content: [ | ||
| { type: "text", text: "The visual confirms it: ..." }, | ||
| { | ||
| type: "tool_use", | ||
| id: "tooluse_UDLZ6mSXfpiIeVAHk5NnIR", | ||
| name: "execute_command", | ||
| input: { command: "WS=/..." }, | ||
| }, | ||
| { | ||
| type: "tool_use", | ||
| id: "tooluse_NudlJpcjeQemU5wudkIYMA", | ||
| name: "execute_command", | ||
| input: { command: "git diff --stat -- python/" }, | ||
| }, | ||
| ], | ||
| } | ||
|
|
||
| const userMessage: Anthropic.MessageParam = { | ||
| role: "user", | ||
| content: [ | ||
| { | ||
| type: "tool_result", | ||
| tool_use_id: "tooluse_UDLZ6mSXfpiIeVAHk5NnIR", | ||
| content: "Exit code: 0", | ||
| }, | ||
| { | ||
| type: "image", | ||
| source: { type: "base64", media_type: "image/png", data: "iVBOR" }, | ||
| }, | ||
| { | ||
| type: "tool_result", | ||
| tool_use_id: "tooluse_NudlJpcjeQemU5wudkIYMA", | ||
| content: "Exit code: 0", | ||
| }, | ||
| { type: "text", text: "<user_message>Something wrong with the tool use?</user_message>" }, | ||
| { type: "text", text: "<environment_details>...</environment_details>" }, | ||
| ], | ||
| } | ||
|
|
||
| const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) | ||
| const content = result.content as Anthropic.Messages.ContentBlockParam[] | ||
|
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Narrow These assertions bypass the As per coding guidelines, “If an unavoidable cast is required, document why in a nearby comment.” Proposed test helper+function getContentBlocks(message: Anthropic.MessageParam): Anthropic.Messages.ContentBlockParam[] {
+ if (!Array.isArray(message.content)) {
+ throw new Error("Expected array message content")
+ }
+ return message.content
+}
+
-const content = result.content as Anthropic.Messages.ContentBlockParam[]
+const content = getContentBlocks(result)Also applies to: 1076-1076, 1105-1105 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| // Both tool_results must form one contiguous leading run. | ||
| expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "image", "text", "text"]) | ||
| // IDs and content are preserved, and no synthetic "interrupted" result is invented. | ||
| expect((content[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tooluse_UDLZ6mSXfpiIeVAHk5NnIR") | ||
| expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tooluse_NudlJpcjeQemU5wudkIYMA") | ||
| expect((content[1] as Anthropic.ToolResultBlockParam).content).toBe("Exit code: 0") | ||
| // Telemetry should not fire: nothing is missing or mismatched, only misordered. | ||
| expect(TelemetryService.instance.captureException).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should hoist tool_results that follow a text block", () => { | ||
| const assistantMessage: Anthropic.MessageParam = { | ||
| role: "assistant", | ||
| content: [ | ||
| { type: "tool_use", id: "tool-1", name: "read_file", input: {} }, | ||
| { type: "tool_use", id: "tool-2", name: "read_file", input: {} }, | ||
| ], | ||
| } | ||
|
|
||
| const userMessage: Anthropic.MessageParam = { | ||
| role: "user", | ||
| content: [ | ||
| { type: "text", text: "Here are the results:" }, | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| ], | ||
| } | ||
|
|
||
| const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) | ||
| const content = result.content as Anthropic.Messages.ContentBlockParam[] | ||
|
|
||
| expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "text"]) | ||
| expect((content[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1") | ||
| expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") | ||
| }) | ||
|
|
||
| it("should deduplicate and hoist without mismatching IDs by position", () => { | ||
| // The duplicate must be dropped BEFORE positional ID correction, otherwise the | ||
| // interleaved ordering could cause a valid result to be reassigned the wrong ID. | ||
| const assistantMessage: Anthropic.MessageParam = { | ||
| role: "assistant", | ||
| content: [ | ||
| { type: "tool_use", id: "tool-1", name: "read_file", input: {} }, | ||
| { type: "tool_use", id: "tool-2", name: "read_file", input: {} }, | ||
| ], | ||
| } | ||
|
|
||
| const userMessage: Anthropic.MessageParam = { | ||
| role: "user", | ||
| content: [ | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "duplicate" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| ], | ||
| } | ||
|
|
||
| const result = validateAndFixToolResultIds(userMessage, [assistantMessage]) | ||
| const content = result.content as Anthropic.Messages.ContentBlockParam[] | ||
|
|
||
| expect(content.map((block) => block.type)).toEqual(["tool_result", "tool_result", "image"]) | ||
| expect((content[0] as Anthropic.ToolResultBlockParam).content).toBe("A") | ||
| expect((content[1] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-2") | ||
| expect((content[1] as Anthropic.ToolResultBlockParam).content).toBe("B") | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| describe("hoistToolResultsToFront", () => { | ||
| it("returns the same array reference when tool_results are already contiguous at the front", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [ | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, | ||
| { type: "text", text: "env" }, | ||
| ] | ||
|
|
||
| expect(hoistToolResultsToFront(content)).toBe(content) | ||
| }) | ||
|
|
||
| it("returns the same array reference when there are no tool_result blocks", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [ | ||
| { type: "text", text: "hello" }, | ||
| { type: "image", source: { type: "base64", media_type: "image/png", data: "x" } }, | ||
| ] | ||
|
|
||
| expect(hoistToolResultsToFront(content)).toBe(content) | ||
| }) | ||
|
|
||
| it("returns the same array reference for an empty array", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [] | ||
| expect(hoistToolResultsToFront(content)).toBe(content) | ||
| }) | ||
|
|
||
| it("returns the same array reference when every block is a tool_result", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [ | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| ] | ||
|
|
||
| expect(hoistToolResultsToFront(content)).toBe(content) | ||
| }) | ||
|
|
||
| it("preserves the relative order of both tool_results and other blocks", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [ | ||
| { type: "text", text: "first" }, | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "text", text: "second" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| { type: "text", text: "third" }, | ||
| ] | ||
|
|
||
| const result = hoistToolResultsToFront(content) | ||
|
|
||
| expect(result).toEqual([ | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| { type: "tool_result", tool_use_id: "tool-2", content: "B" }, | ||
| { type: "text", text: "first" }, | ||
| { type: "text", text: "second" }, | ||
| { type: "text", text: "third" }, | ||
| ]) | ||
| }) | ||
|
|
||
| it("does not mutate the input array", () => { | ||
| const content: Anthropic.Messages.ContentBlockParam[] = [ | ||
| { type: "text", text: "first" }, | ||
| { type: "tool_result", tool_use_id: "tool-1", content: "A" }, | ||
| ] | ||
| const snapshot = [...content] | ||
|
|
||
| hoistToolResultsToFront(content) | ||
|
|
||
| expect(content).toEqual(snapshot) | ||
| }) | ||
| }) | ||
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use a typed guard or document the
msg.contentassertion.Line 4677 casts the broader
ApiMessagecontent array to Anthropic blocks. Add a typed guard that proves the element shape, or document why every array in this user-message path is guaranteed to containContentBlockParamvalues.As per coding guidelines, “If an unavoidable cast is required, document why in a nearby comment.”
🤖 Prompt for AI Agents
Source: Coding guidelines