fix(claude_code): flatten multi-turn history into single user envelope (2.0.4)#206
Merged
Conversation
…ope (2.0.4)
Claude Code (CLI) Stage-6 sessions failed at the second turn with::
Error: CLI '/usr/bin/claude' exited with code 1:
Error: Expected message role 'user', got 'assistant'
Geny's s06_api stage accumulates an Anthropic-style multi-turn
message list across loop iterations:
``[user, assistant_w_tool_use, user_w_tool_result, ...]``. The
previous ``build_stream_json_stdin`` envelopes each canonical
message as ``{"type":"user","message":{"role":<original>, ...}}``
and writes one line per message. Claude Code 2.x's stream-json
input parser strictly requires every envelope's ``message.role`` to
be ``"user"`` — embedded ``assistant`` / ``tool`` roles are
rejected.
### Fix
``build_stream_json_stdin`` now collapses the entire canonical
history into a **single synthetic ``type:user`` envelope** whose
``content`` is a markdown preamble + the current input:
## Conversation so far
### User
find the README
### Assistant
Let me check.
[Tool call: Read({"path": "/repo/README.md"})]
### Tool result
[Tool result] # Hello
## Current input
summarize it
The LLM reconstructs the conversation from the structure; the CLI
sees one cohesive single-turn prompt. Single-turn callers (one user
message only) skip the preamble and emit the canonical envelope
unchanged so simple invocations stay byte-for-byte identical to the
legacy path. Thinking blocks from a prior provider are dropped (CLI
does its own ``--effort`` thinking on the new turn). Tool errors
render under a separate ``[Tool error]`` tag so the LLM sees the
failure semantics.
### Why
Provider-neutral OUTPUT contract was already restored in 2.0.3
(``StreamJsonAccumulator``). The remaining asymmetry was on the
INPUT side: every provider — anthropic / openai / google / vllm /
claude_code_cli / copilot_cli — must accept the same canonical
message-list shape and translate internally to whatever the
underlying surface wants. The CLI's stream-json input grammar is
strict user-only; the executor owns the translation so hosts never
see the difference.
### Tests
- ``test_stdin_envelope_multi_turn_always_user_role`` — collapses
user/assistant/user-with-tool_result into one ``message.role:user``
envelope.
- ``test_stdin_envelope_multi_turn_preserves_history_in_content`` —
preamble carries text, tool calls (name + input json), and tool
results under markdown headers; the final user turn is the
"Current input" block.
- ``test_stdin_envelope_drops_thinking_and_handles_tool_errors`` —
thinking blocks dropped; ``is_error: True`` tool_results tagged
``[Tool error]``.
- Single-turn fast path test unchanged (back-compat verified).
Full ``tests/llm_client/`` 189/189 pass.
2 tasks
CocoRoF
added a commit
to CocoRoF/Geny
that referenced
this pull request
May 19, 2026
…din) (#816) 2.0.4 fixes ``build_stream_json_stdin`` to flatten Anthropic-style multi-turn message history into a single synthetic ``type:user`` envelope. Without this, every second turn of a Claude Code (CLI) Stage-6 session failed with:: Error: CLI '/usr/bin/claude' exited with code 1: Error: Expected message role 'user', got 'assistant' — the CLI's stream-json input grammar rejects envelopes carrying an embedded assistant / tool role. The executor now owns the translation so hosts can keep sending the canonical Anthropic-style multi-turn list shape regardless of which provider is wired up to Stage 6. See CocoRoF/geny-executor#206 for the full root-cause + design. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Claude Code (CLI) Stage-6 sessions failed at the second turn with:
User screenshot showed a VTuber session attempting tool calls — the first turn succeeded (assistant returned tool_use blocks), Geny executed the tools and sent the next iteration with
[user, assistant_w_tool_use, user_w_tool_result], and the CLI rejected it.Root cause
Geny's s06_api stage accumulates an Anthropic-style multi-turn message list across loop iterations. The previous
build_stream_json_stdinenvelopes each canonical message as{"type":"user","message":{"role":<original>, ...}}and writes one line per message. Claude Code 2.x's stream-json input parser strictly requires every envelope'smessage.roleto be"user"— embeddedassistant/toolroles are rejected.Fix
build_stream_json_stdincollapses the entire canonical history into a single synthetictype:userenvelope whosecontentis a markdown preamble + the current input:The LLM reconstructs the conversation from the structure; the CLI sees one cohesive single-turn prompt.
Provider-neutrality
Per the user's directive — every provider (anthropic / openai / google / vllm / claude_code_cli / copilot_cli) must accept the same canonical message-list shape and translate internally. The CLI's stream-json input grammar is strict user-only; the executor owns the translation so hosts never see the difference. Combined with the 2.0.3
StreamJsonAccumulator(output contract), the full input + output round-trip is now provider-symmetric.Edge cases handled
--effortthinking on the new turn)is_error: Truetool_results render under a[Tool error]tag instead of[Tool result][image attachment]placeholderTest plan
pytest tests/llm_client/189/189 passtest_stdin_envelope_multi_turn_always_user_roletest_stdin_envelope_multi_turn_preserves_history_in_contenttest_stdin_envelope_drops_thinking_and_handles_tool_errorsRelease
2.0.4. Migration: none.