Skip to content

fix: keep parallel tool results consecutive before carried image messages - #33

Merged
Mars-Sea merged 1 commit into
Mars-Sea:mainfrom
A8Chann:fix/tool-image-carry-order
Sep 11, 2026
Merged

fix: keep parallel tool results consecutive before carried image messages#33
Mars-Sea merged 1 commit into
Mars-Sea:mainfrom
A8Chann:fix/tool-image-carry-order

Conversation

@A8Chann

@A8Chann A8Chann commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What

Both message converters emitted a tool-result image carrier as a user message immediately after its own tool result. When one assistant turn contains several parallel tool calls whose results carry images (e.g. two read_image calls), the request came out as:

assistant(tool_calls A,B) -> tool(A) -> user(image A) -> tool(B) -> user(image B)

The Command Code gateway validates that an assistant message's tool blocks are answered consecutively, so the interleaved user message rejected the whole request:

400 An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following
tool_calls message)

This fires on every history replay of such a session, so the conversation cannot continue at all (a retry does not help; only switching provider/model works).

Fix

Buffer the carries in a pendingImages list and flush them only after the whole tool group has been emitted - right before the next non-tool message (user or assistant), or at the end of the loop. Both transports are fixed:

  • messagesToCC (CLI /alpha/generate)
  • messagesToOpenAI (Provider API /provider/v1/chat/completions)

Result:

assistant(A,B) -> tool(A) -> tool(B) -> user(image A) -> user(image B)

Single-call turns are unchanged (tool -> user(image)), so the issue #30 shape is preserved.

Tests

Two regression tests pin the ordering on both transports (stream() keeps parallel tool results consecutive before their image carriers, openai protocol groups carried images after the whole parallel tool turn). npm run typecheck and the full node --import tsx --test tests/*.test.ts suite (91 tests) pass. lib/ was rebuilt with npm run build and staged with git add -f.

Repro notes

Observed with a DeepSeek Harness session where one assistant turn issued two parallel read_image calls; every later turn on commandcode failed with that 400 until the offending history was left behind.

An assistant turn can issue several parallel tool calls and each tool
result can carry images (e.g. read_image). Both message converters
emitted the image carrier as a user message immediately after each tool
result, so a two-call turn went assistant -> tool -> user(image) ->
tool -> user(image). The Command Code gateway validates that an
assistant's tool blocks are answered consecutively, so the interleaved
user message made the whole request fail with 'An assistant message with
tool_calls must be followed by tool messages responding to each
tool_call_id' (HTTP 400) on every replay of the history.

Buffer the image carriers in a pending list and flush them only once the
whole tool group has been emitted (or the next non-tool message is
reached), for both the CLI (messagesToCC) and OpenAI
(messagesToOpenAI) transports. Adds regression tests pinning the
assistant -> tool -> tool -> user -> user ordering.
@Mars-Sea
Mars-Sea merged commit 0b391ca into Mars-Sea:main Sep 11, 2026
5 checks passed
Mars-Sea added a commit that referenced this pull request Sep 12, 2026
A turn's tool-result image carriers travel as a group after the whole tool
group (issue #33), so nothing but position tied an image back to its own
result. Every read_image result renders the same envelope text, so two
parallel calls produced two identical notes and the model had to infer the
pairing from order alone.

The note now names the call: 'Attached image(s) from tool result
(call-a):'. It uses the WIRE id — the one the model saw on the tool-call it
issued and on the tool message just above — so an overlong cross-provider id
is named by the short alias that replaced it (issue #23), not by the
harness-side id that appears nowhere in the request. Both converters now
resolve that id once and use it for the tool message and the note.

Also rebuilds lib/, which incidentally normalizes the CRLF line endings a
CRLF checkout baked into both committed sourcemaps' sourcesContent.

Tests: the two parallel-carrier tests now assert each note names its own
call, plus a new test pinning that a remapped overlong id is named by its
alias rather than the harness id. Each fails against the previous adapter.
Mars-Sea added a commit that referenced this pull request Sep 12, 2026
The two converters disagreed about the same input: the CLI transport emitted
`{ role: 'user', content: [] }` for a user message with no text and no image
to put on the wire, while the Provider API transport skipped it. An empty
message carries nothing to the model, and an empty content array is a needless
gateway-compat risk, so both transports now skip it. (dsh-llm-deepseek pushes
`content: ''` instead; skipping is the safer half of that divergence to keep.)

The flush of pending tool-result image carriers runs before the skip on both
transports, so an image queued behind an empty message is still emitted rather
than dropped with it — pinned by the new test.

Also adds the gateway's tool-group rule as an explicit, reusable invariant
instead of pinning shapes one at a time. One table of ten representative
histories now runs through both transports and asserts, from the emitted wire
body, that every assistant message's tool calls are answered consecutively
with nothing interleaved. It re-derives the rule from the output rather than
the input, so it holds for both wire shapes and survives a converter
refactor. Verified to fail against the pre-#33 adapter with the expected
diagnostic, and the invariant carries its own test so it cannot pass
vacuously.

Note: tests/ is outside the tsconfig include list, so `npm run typecheck`
never checked this file — an unimported `ContentBlock` (pre-existing, plus one
this commit introduced) went unnoticed. Both imports are now declared.
Mars-Sea added a commit that referenced this pull request Sep 12, 2026
- Thinking models no longer fail every turn after the first tool call. The
  /alpha/generate transport had stopped replaying historical reasoning, but
  the provider rebuilds its request from those parts and rejects an assistant
  tool call that arrives without them, so a Go-plan or fallback session was
  unusable with a thinking model. Reasoning is replayed in content order
  again, on both transports (issue #34).
- A tool whose parameter schema has no object root no longer kills the whole
  turn. The gateway validates every function schema root and refuses the
  request otherwise, so one badly declared third-party or MCP tool made every
  model and every tool fail. Both bodies now normalize the root: an
  object-rooted schema is untouched, a type-less object-shaped one gains the
  type, a union is collapsed, a root $ref is inlined, and a non-object root
  degrades to a permissive object (issue #35).
- Reading several images in one turn no longer makes the conversation
  unusable. Image carriers are buffered until the whole tool group has been
  emitted, so parallel tool results stay adjacent, and each carrier names the
  tool call it came from (issue #33).
- A user message that converts to nothing is dropped by both transports
  instead of being sent as an empty content array.
- npm run typecheck now checks the test suite too; no shipped behavior change.
- Synced with command-code@1.53.1. The catalog, effort map, plan tiers,
  subscription maps, deals, peak/off-peak membership and schedule, and the
  wire format are all unchanged; only the request-header version moved.
  gpt-5.6-terra/luna are now routed upstream through vercel-ai-gateway (same
  efforts, modalities and context), which is invisible to this plugin.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants