Skip to content

fix(claude_code): flatten multi-turn history into single user envelope (2.0.4)#206

Merged
CocoRoF merged 1 commit into
mainfrom
fix/claude-code-multiturn-stdin
May 19, 2026
Merged

fix(claude_code): flatten multi-turn history into single user envelope (2.0.4)#206
CocoRoF merged 1 commit into
mainfrom
fix/claude-code-multiturn-stdin

Conversation

@CocoRoF
Copy link
Copy Markdown
Owner

@CocoRoF CocoRoF commented May 19, 2026

Summary

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'

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_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 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.

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

  • Single-turn fast path: one user message → emit the canonical envelope unchanged (byte-for-byte legacy compat)
  • Thinking blocks: dropped (CLI does its own --effort thinking on the new turn)
  • Tool errors: is_error: True tool_results render under a [Tool error] tag instead of [Tool result]
  • Image blocks: render as [image attachment] placeholder
  • String + list content: both shapes handled

Test plan

  • pytest tests/llm_client/ 189/189 pass
  • New regression guards:
    • test_stdin_envelope_multi_turn_always_user_role
    • test_stdin_envelope_multi_turn_preserves_history_in_content
    • test_stdin_envelope_drops_thinking_and_handles_tool_errors
  • Manual on prod: VTuber session with env=claude_code_cli, send "워커한테 메시지 보내서 자기소개 해" → multi-turn loop completes without "Expected message role 'user'" error

Release

2.0.4. Migration: none.

…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.
@CocoRoF CocoRoF merged commit 65a7814 into main May 19, 2026
5 of 6 checks passed
@CocoRoF CocoRoF deleted the fix/claude-code-multiturn-stdin branch May 19, 2026 08:13
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>
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.

1 participant