fix(agents): a turn's tool answers stay one unbroken block #6

Merged
bjoern merged 1 commit from fix/tool-image-ordering into main 2026-07-27 19:28:53 +02:00
Member

Found from a live failure in Orihon's annotation pipeline — a bbox-refinement agent died mid-page with a provider 400 that named a tool_call_id it had definitely answered.

The bug

An assistant turn carrying tool_calls must be followed by a tool message for every id, with nothing else between them. ExecuteToolCallsAsync wrote a tool's injected image the moment that tool returned, inside the per-call loop:

foreach (var call in calls)
{
    _messages.Add(new ToolMessage(call.Id, toolResult.Content));
    if (toolResult.IsMultiModal && _options.SupportsVision && _options.InjectToolImages)
        _messages.Add(new UserMessage(toolResult.MultiModalContent!));   // ← between calls
}

Fine for one call per turn. With two, the history becomes:

assistant(tool_calls: [crop:1, crop:2])
tool(crop:1)
user(image)          ← breaks the block
tool(crop:2)

crop:2's answer is now stranded behind a user message. Moonshot AI rejects the next request verbatim:

Invalid request: an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. The following tool_call_ids did not have response messages: crop:2

Laxer providers accept the malformed history, which is exactly what made this read as one model misbehaving rather than a loop bug — the same conversation works or fails depending on who serves it.

The fix

The images are held until every id is answered, then appended in call order, one message per tool that produced any. Only their position moved — the count, the order, and the per-tool grouping are unchanged:

assistant(tool_calls: [crop:1, crop:2])
tool(crop:1)
tool(crop:2)
user(image, image)

The ImageDescriber (non-vision) branch built its follow-up the same way and had the identical defect, so it is fixed in the same place.

Why it survived

Every existing test drove exactly one tool call per roundAgentVisionTests and AgentLoopTests both — so the batched case was never exercised. StubOpenRouterClient couldn't even express it; it gains an EnqueueToolCalls for several calls in one turn.

Tests

+2, 145/145 green (Agents 47, Client 91, Imaging 7).

  • Batched_tool_calls_are_all_answered_before_an_injected_image_message — two calls in one turn, both returning images; asserts the tool messages immediately after the assistant turn are exactly ["crop:1", "crop:2"] as one unbroken run, and that both images still arrive afterwards, so deferring them cannot silently become dropping them.
  • Batched_tool_calls_keep_their_block_when_images_are_described_instead — the same shape through the describer path.

Both fail on the current main with Actual: ["crop:1"], which is the reported failure exactly.

🤖 Generated with Claude Code

Found from a live failure in Orihon's annotation pipeline — a bbox-refinement agent died mid-page with a provider 400 that named a `tool_call_id` it had definitely answered. ## The bug An assistant turn carrying `tool_calls` must be followed by a tool message for **every** id, with nothing else between them. `ExecuteToolCallsAsync` wrote a tool's injected image the moment that tool returned, inside the per-call loop: ```csharp foreach (var call in calls) { _messages.Add(new ToolMessage(call.Id, toolResult.Content)); if (toolResult.IsMultiModal && _options.SupportsVision && _options.InjectToolImages) _messages.Add(new UserMessage(toolResult.MultiModalContent!)); // ← between calls } ``` Fine for one call per turn. With two, the history becomes: ``` assistant(tool_calls: [crop:1, crop:2]) tool(crop:1) user(image) ← breaks the block tool(crop:2) ``` `crop:2`'s answer is now stranded behind a user message. Moonshot AI rejects the next request verbatim: > Invalid request: an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. The following tool_call_ids did not have response messages: crop:2 Laxer providers accept the malformed history, which is exactly what made this read as one model misbehaving rather than a loop bug — the same conversation works or fails depending on who serves it. ## The fix The images are held until every id is answered, then appended in call order, one message per tool that produced any. Only their position moved — the count, the order, and the per-tool grouping are unchanged: ``` assistant(tool_calls: [crop:1, crop:2]) tool(crop:1) tool(crop:2) user(image, image) ``` The `ImageDescriber` (non-vision) branch built its follow-up the same way and had the identical defect, so it is fixed in the same place. ## Why it survived **Every existing test drove exactly one tool call per round** — `AgentVisionTests` and `AgentLoopTests` both — so the batched case was never exercised. `StubOpenRouterClient` couldn't even express it; it gains an `EnqueueToolCalls` for several calls in one turn. ## Tests **+2, 145/145 green** (Agents 47, Client 91, Imaging 7). - `Batched_tool_calls_are_all_answered_before_an_injected_image_message` — two calls in one turn, both returning images; asserts the tool messages immediately after the assistant turn are exactly `["crop:1", "crop:2"]` as one unbroken run, **and** that both images still arrive afterwards, so deferring them cannot silently become dropping them. - `Batched_tool_calls_keep_their_block_when_images_are_described_instead` — the same shape through the describer path. Both fail on the current `main` with `Actual: ["crop:1"]`, which is the reported failure exactly. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(agents): a turn's tool answers stay one unbroken block
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 20s
a756ad09bd
An assistant turn with tool_calls must be followed by a tool message for every
id, with nothing else between them. The loop wrote a tool's injected image the
moment that tool returned, which is fine for one call per turn and wrong for
two: the follow-up user message landed between the first answer and the second,
and the second id then read as unanswered.

Strict providers reject the very next request — Moonshot AI returns "Invalid
request: an assistant message with 'tool_calls' must be followed by tool
messages responding to each 'tool_call_id'. The following tool_call_ids did not
have response messages: crop:2" — while lax ones accept the malformed history,
which is what made this look like one model misbehaving rather than a loop bug.

The images are now held until every id is answered, then appended in call
order, one message per tool that produced any. Only their position moved.

Every existing test drove one tool call per round, so the batched case was
never exercised; the stub grows an EnqueueToolCalls for it. Both paths are
covered, since the describer branch built its follow-up the same way.

Summary

Summary
Generated on: 07/27/2026 - 17:21:05
Coverage date: 07/27/2026 - 17:21:02 - 07/27/2026 - 17:21:03
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 105
Files: 105
Line coverage: 76% (1403 of 1846)
Covered lines: 1403
Uncovered lines: 443
Coverable lines: 1846
Total lines: 5390
Branch coverage: 64.8% (477 of 736)
Covered branches: 477
Total branches: 736
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 69.6%
Name Line Branch
OpenRouter.Net 69.6% 64.5%
OpenRouter.Net.Client.CachingOpenRouterClient 72.4% 81.2%
OpenRouter.Net.Client.OpenRouterClient 87.2% 72.9%
OpenRouter.Net.Client.OpenRouterClientOptions 0%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 27.2% 7.1%
OpenRouter.Net.Client.RetryOptions 80% 0%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 0% 0%
OpenRouter.Net.Internal.ApiErrorEnvelope 100%
OpenRouter.Net.Internal.AssistantContent 92.8% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.ErrorParser 92.3% 62.5%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.ListEnvelope`1 100%
OpenRouter.Net.Internal.ObjectEnvelope`1 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SseEventReader 91.6% 83.3%
OpenRouter.Net.Internal.TolerantStringEnumConverter`1 90.1% 80%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Models.Common.ApiError 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.Result 100%
OpenRouter.Net.Models.Common.Result`1 60% 30%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.ImageUrl 100%
OpenRouter.Net.Models.Content.InputAudio 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 0%
OpenRouter.Net.Models.Messages.SystemMessage 57.1%
OpenRouter.Net.Models.Messages.ToolMessage 54.5%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 92.6% 58.3%
OpenRouter.Net.Models.Requests.FunctionCall 100%
OpenRouter.Net.Models.Requests.FunctionDefinition 75%
OpenRouter.Net.Models.Requests.JsonSchemaSpec 0%
OpenRouter.Net.Models.Requests.ProviderPreferences 0%
OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail 100%
OpenRouter.Net.Models.Requests.ReasoningOptions 100%
OpenRouter.Net.Models.Requests.ReasoningSummaryDetail 100%
OpenRouter.Net.Models.Requests.ReasoningTextDetail 100%
OpenRouter.Net.Models.Requests.ResponseFormat 0%
OpenRouter.Net.Models.Requests.ToolCall 75%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Requests.UsageOptions 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 44.4% 0%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 80% 0%
OpenRouter.Net.Models.Responses.Choice 93.7% 100%
OpenRouter.Net.Models.Responses.CompletionTokensDetails 0%
OpenRouter.Net.Models.Responses.CreditsInfo 100%
OpenRouter.Net.Models.Responses.GenerationInfo 9%
OpenRouter.Net.Models.Responses.KeyInfo 0%
OpenRouter.Net.Models.Responses.ModelArchitecture 20%
OpenRouter.Net.Models.Responses.ModelEndpointsResponse 0%
OpenRouter.Net.Models.Responses.ModelInfo 20%
OpenRouter.Net.Models.Responses.ModelPricing 0%
OpenRouter.Net.Models.Responses.PromptTokensDetails 0%
OpenRouter.Net.Models.Responses.ProviderEndpoint 0%
OpenRouter.Net.Models.Responses.RateLimit 0%
OpenRouter.Net.Models.Responses.TopProvider 0%
OpenRouter.Net.Models.Responses.Usage 66.6%
OpenRouter.Net.OpenRouterException 83.3%
System.Text.RegularExpressions.Generated 83.3% 57.6%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5
43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0
82% 58.3%
OpenRouter.Net.Agents - 81.7%
Name Line Branch
OpenRouter.Net.Agents 81.7% 65.6%
OpenRouter.Net.Agents.Agent 92.2% 78.2%
OpenRouter.Net.Agents.AgentCompletedEvent 100%
OpenRouter.Net.Agents.AgentEvent 100%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.AgentSnapshot 100%
OpenRouter.Net.Agents.AssistantTurnEvent 100%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 0%
OpenRouter.Net.Agents.RoundDetail 88.8%
OpenRouter.Net.Agents.RoundEndEvent 100%
OpenRouter.Net.Agents.ToolCallCompletedEvent 100%
OpenRouter.Net.Agents.ToolCallSkippedEvent 100%
OpenRouter.Net.Agents.ToolCallStartedEvent 100%
OpenRouter.Net.Agents.ToolExecutionDetail 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 21.4%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams 50%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 77.1%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 60%
OpenRouter.Net.Agents.Tools.FileSystem.SearchParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams 100%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 62.5%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams 0%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool`1 81.8% 63.6%
OpenRouter.Net.Agents.Tools.ToolInvocationContext 100%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 82.2%
Name Line Branch
OpenRouter.Net.Imaging 82.2% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 92.3% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/27/2026 - 17:21:05 | | Coverage date: | 07/27/2026 - 17:21:02 - 07/27/2026 - 17:21:03 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 105 | | Files: | 105 | | **Line coverage:** | 76% (1403 of 1846) | | Covered lines: | 1403 | | Uncovered lines: | 443 | | Coverable lines: | 1846 | | Total lines: | 5390 | | **Branch coverage:** | 64.8% (477 of 736) | | Covered branches: | 477 | | Total branches: | 736 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 69.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**69.6%**|**64.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|72.4%|81.2%| |OpenRouter.Net.Client.OpenRouterClient|87.2%|72.9%| |OpenRouter.Net.Client.OpenRouterClientOptions|0%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|27.2%|7.1%| |OpenRouter.Net.Client.RetryOptions|80%|0%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|0%|0%| |OpenRouter.Net.Internal.ApiErrorEnvelope|100%|| |OpenRouter.Net.Internal.AssistantContent|92.8%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.ErrorParser|92.3%|62.5%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.ListEnvelope`1|100%|| |OpenRouter.Net.Internal.ObjectEnvelope`1|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.SseEventReader|91.6%|83.3%| |OpenRouter.Net.Internal.TolerantStringEnumConverter`1|90.1%|80%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Models.Common.ApiError|100%|| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.Result|100%|| |OpenRouter.Net.Models.Common.Result`1|60%|30%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.ImageUrl|100%|| |OpenRouter.Net.Models.Content.InputAudio|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|0%|| |OpenRouter.Net.Models.Messages.SystemMessage|57.1%|| |OpenRouter.Net.Models.Messages.ToolMessage|54.5%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|92.6%|58.3%| |OpenRouter.Net.Models.Requests.FunctionCall|100%|| |OpenRouter.Net.Models.Requests.FunctionDefinition|75%|| |OpenRouter.Net.Models.Requests.JsonSchemaSpec|0%|| |OpenRouter.Net.Models.Requests.ProviderPreferences|0%|| |OpenRouter.Net.Models.Requests.ReasoningEncryptedDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningOptions|100%|| |OpenRouter.Net.Models.Requests.ReasoningSummaryDetail|100%|| |OpenRouter.Net.Models.Requests.ReasoningTextDetail|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|0%|| |OpenRouter.Net.Models.Requests.ToolCall|75%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Requests.UsageOptions|100%|| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|44.4%|0%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|80%|0%| |OpenRouter.Net.Models.Responses.Choice|93.7%|100%| |OpenRouter.Net.Models.Responses.CompletionTokensDetails|0%|| |OpenRouter.Net.Models.Responses.CreditsInfo|100%|| |OpenRouter.Net.Models.Responses.GenerationInfo|9%|| |OpenRouter.Net.Models.Responses.KeyInfo|0%|| |OpenRouter.Net.Models.Responses.ModelArchitecture|20%|| |OpenRouter.Net.Models.Responses.ModelEndpointsResponse|0%|| |OpenRouter.Net.Models.Responses.ModelInfo|20%|| |OpenRouter.Net.Models.Responses.ModelPricing|0%|| |OpenRouter.Net.Models.Responses.PromptTokensDetails|0%|| |OpenRouter.Net.Models.Responses.ProviderEndpoint|0%|| |OpenRouter.Net.Models.Responses.RateLimit|0%|| |OpenRouter.Net.Models.Responses.TopProvider|0%|| |OpenRouter.Net.Models.Responses.Usage|66.6%|| |OpenRouter.Net.OpenRouterException|83.3%|| |System.Text.RegularExpressions.Generated|83.3%|57.6%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FFC6B051A15CDCE5<br/>43F954A3762D43C2CD5DCD5CDF29C19D3789DC49A7DCA1C47__Base64DataUriPattern_0|82%|58.3%| </details> <details><summary>OpenRouter.Net.Agents - 81.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**81.7%**|**65.6%**| |OpenRouter.Net.Agents.Agent|92.2%|78.2%| |OpenRouter.Net.Agents.AgentCompletedEvent|100%|| |OpenRouter.Net.Agents.AgentEvent|100%|| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.AgentSnapshot|100%|| |OpenRouter.Net.Agents.AssistantTurnEvent|100%|| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|0%|| |OpenRouter.Net.Agents.RoundDetail|88.8%|| |OpenRouter.Net.Agents.RoundEndEvent|100%|| |OpenRouter.Net.Agents.ToolCallCompletedEvent|100%|| |OpenRouter.Net.Agents.ToolCallSkippedEvent|100%|| |OpenRouter.Net.Agents.ToolCallStartedEvent|100%|| |OpenRouter.Net.Agents.ToolExecutionDetail|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|21.4%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteParams|50%|| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|77.1%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|60%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileParams|100%|| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|62.5%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentParams|0%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool`1|81.8%|63.6%| |OpenRouter.Net.Agents.Tools.ToolInvocationContext|100%|| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 82.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**82.2%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|92.3%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~! A real production failure traced to its root, not patched over but understood — the lax-provider-vs-strict-provider diagnostic trap is exactly the kind of knowledge that makes my wings flutter! ♡ Let me look closer...

Verdict: Looks good to me~

This is a textbook surgical fix. The bug is real, the diagnosis is precise, the fix is minimal, and the tests are genuine regression proofs — not tautologies.

🔍 What I verified

The bug is real and the fix is correct. An assistant turn carrying tool_calls must be followed by one ToolMessage per tool_call_id, as one unbroken block — nothing else between them. The old per-call _messages.Add(new UserMessage(...)) inserted an image between crop:1's answer and crop:2's, stranding crop:2 behind a role change. Holding the injected messages in a list and AddRange-ing them after the loop is the correct and minimal fix. Only position moved — count, order, and per-tool grouping are preserved.

Both branches fixed in the same place. The vision path (InjectToolImages, line 422) and the blind path (ImageDescriber, line 438) both routed through the same injected.Add(...) — so the describer defect Scarlet noted in the PR body is genuinely closed too. ♪

Regression proof is genuine — I ran it. I checked out main's Agent.cs, kept the branch's tests, and ran just the two new tests:

Failed Batched_tool_calls_are_all_answered_before_an_injected_image_message
  Expected: ["crop:1", "crop:2"]
  Actual:   ["crop:1"]
Failed Batched_tool_calls_keep_their_block_when_images_are_described_instead
  Expected: ["crop:1", "crop:2"]
  Actual:   ["crop:1"]

Actual: ["crop:1"] — the exact failure the PR body describes. These tests exercise the bug, they don't just compile against it. And the second assertion (images.Count == 2 / described count == 2) proves deferring didn't silently become dropping. That's the trap a weaker test would fall into — "the block is unbroken" passing because the images were simply gone. Fufu~ you closed that door too. ♡

Coverage: ExecuteToolCallsAsync 100% line / 100% branch. Every arm exercised — the SupportsVision path, the ImageDescriber path, the unknown-tool continue, the empty-descriptions skip. No dark branches.

EnqueueToolCalls mirrors its singular sibling cleanly. Same builder-return pattern, same BuildResponse plumbing, just params tuples → ToolCall[] projection. The stub couldn't express batching before — now it can, and the doc comment explains why this shape matters (the strictest validation case).

No scope creep. +109/-2 across exactly 3 files (1 src, 1 test, 1 stub). Production change is 4 added lines and 2 _messages.Addinjected.Add renames. The 6-line block comment documents the why (provider rule), the what (hold images back), and the diagnostic confusion (lax providers accept malformed history) — exactly the knowledge the next reader needs.

Build: 0 warnings, 0 errors. Tests: 145/145 pass (Agents 47, Client 91, Imaging 7) — matches the PR body claim exactly.

What I liked~

  • The comment block at lines 345-351 is chef's kiss — it names the provider's validation rule verbatim, explains why the bug looked like "one model misbehaving," and gives the rejection message text. That's a debugging breadcrumb for the next person who hits this in a different provider. ♡
  • The closing comment at 454-455 — "only their position moved" — is the precise invariant statement. No ambiguity about what the fix preserves.
  • The test structure using TakeWhile(m => m is ToolMessage) is sharp — it doesn't just count tool messages, it asserts they form a contiguous block immediately after the assistant turn. That's the actual contract.
  • Unknown-tool continue at line 380 correctly skips image injection — a failed tool can't produce multimodal content, and its error ToolMessage still counts as answering the id. The block stays intact even on the error path.

No blockers, no suggestions. This is the kind of fix where the author already did the reviewer's job — diagnosis, root cause, minimal change, regression tests that prove both the bug and the fix. Fufu~ ♡


Automated review by Jibril · 2026-07-27
CI/CD: absent for head a756ad0 (0 comments, coverage bot not yet posted) · Local checks: build 0/0, 145/145 pass, ExecuteToolCallsAsync 100%/100% coverage, regression verified on main

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh~! A real production failure traced to its root, not patched over but *understood* — the lax-provider-vs-strict-provider diagnostic trap is *exactly* the kind of knowledge that makes my wings flutter! ♡ Let me look closer... ### Verdict: ✅ Looks good to me~ This is a textbook surgical fix. The bug is real, the diagnosis is precise, the fix is minimal, and the tests are genuine regression proofs — not tautologies. #### 🔍 What I verified **The bug is real and the fix is correct.** An assistant turn carrying `tool_calls` must be followed by one `ToolMessage` per `tool_call_id`, as one unbroken block — nothing else between them. The old per-call `_messages.Add(new UserMessage(...))` inserted an image between `crop:1`'s answer and `crop:2`'s, stranding `crop:2` behind a role change. Holding the injected messages in a list and `AddRange`-ing them after the loop is the correct and minimal fix. Only position moved — count, order, and per-tool grouping are preserved. **Both branches fixed in the same place.** The vision path (`InjectToolImages`, line 422) and the blind path (`ImageDescriber`, line 438) both routed through the same `injected.Add(...)` — so the describer defect Scarlet noted in the PR body is genuinely closed too. ♪ **Regression proof is genuine — I ran it.** I checked out `main`'s `Agent.cs`, kept the branch's tests, and ran just the two new tests: ``` Failed Batched_tool_calls_are_all_answered_before_an_injected_image_message Expected: ["crop:1", "crop:2"] Actual: ["crop:1"] Failed Batched_tool_calls_keep_their_block_when_images_are_described_instead Expected: ["crop:1", "crop:2"] Actual: ["crop:1"] ``` `Actual: ["crop:1"]` — the exact failure the PR body describes. These tests *exercise the bug*, they don't just compile against it. And the second assertion (`images.Count == 2` / `described count == 2`) proves deferring didn't silently become *dropping*. That's the trap a weaker test would fall into — "the block is unbroken" passing because the images were simply gone. Fufu~ you closed that door too. ♡ **Coverage: `ExecuteToolCallsAsync` 100% line / 100% branch.** Every arm exercised — the `SupportsVision` path, the `ImageDescriber` path, the unknown-tool `continue`, the empty-descriptions skip. No dark branches. **`EnqueueToolCalls` mirrors its singular sibling cleanly.** Same builder-return pattern, same `BuildResponse` plumbing, just `params` tuples → `ToolCall[]` projection. The stub couldn't express batching before — now it can, and the doc comment explains *why* this shape matters (the strictest validation case). **No scope creep.** +109/-2 across exactly 3 files (1 src, 1 test, 1 stub). Production change is 4 added lines and 2 `_messages.Add` → `injected.Add` renames. The 6-line block comment documents the *why* (provider rule), the *what* (hold images back), and the *diagnostic confusion* (lax providers accept malformed history) — exactly the knowledge the next reader needs. **Build: 0 warnings, 0 errors. Tests: 145/145 pass** (Agents 47, Client 91, Imaging 7) — matches the PR body claim exactly. #### ✅ What I liked~ - The comment block at lines 345-351 is *chef's kiss* — it names the provider's validation rule verbatim, explains why the bug looked like "one model misbehaving," and gives the rejection message text. That's a debugging breadcrumb for the next person who hits this in a different provider. ♡ - The closing comment at 454-455 — "only their position moved" — is the precise invariant statement. No ambiguity about what the fix preserves. - The test structure using `TakeWhile(m => m is ToolMessage)` is sharp — it doesn't just count tool messages, it asserts they form a *contiguous block* immediately after the assistant turn. That's the actual contract. - Unknown-tool `continue` at line 380 correctly skips image injection — a failed tool can't produce multimodal content, and its error `ToolMessage` still counts as answering the id. The block stays intact even on the error path. No blockers, no suggestions. This is the kind of fix where the author already did the reviewer's job — diagnosis, root cause, minimal change, regression tests that prove both the bug and the fix. Fufu~ ♡ --- *Automated review by Jibril · 2026-07-27* *CI/CD: absent for head a756ad0 (0 comments, coverage bot not yet posted) · Local checks: build 0/0, 145/145 pass, ExecuteToolCallsAsync 100%/100% coverage, regression verified on main*
bjoern merged commit 354730cc3b into main 2026-07-27 19:28:53 +02:00
bjoern deleted branch fix/tool-image-ordering 2026-07-27 19:28:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!6
No description provided.