Skip to content

fix: preserve agent_message boundaries to prevent Anthropic signed-thinking replay failures - #93

Closed
Wibias wants to merge 2 commits into
lidge-jun:mainfrom
Wibias:preserve-agent-message-boundaries
Closed

fix: preserve agent_message boundaries to prevent Anthropic signed-thinking replay failures#93
Wibias wants to merge 2 commits into
lidge-jun:mainfrom
Wibias:preserve-agent-message-boundaries

Conversation

@Wibias

@Wibias Wibias commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Preserve agent_message boundaries to prevent Anthropic signed-thinking replay failures

Summary

This change adds explicit parsing support for Responses API agent_message items.

Previously, agent_message items were silently ignored while reconstructing the conversation history. This could remove an important turn boundary between two assistant responses and cause separately generated Anthropic thinking blocks to be combined into a single assistant message.

For Anthropic models using extended or adaptive thinking, those thinking blocks are cryptographically signed and must be replayed exactly as they appeared in the original response. Combining them with content from a later assistant response changes the structure of the signed message and causes Anthropic to reject the request with HTTP 400.

The parser now preserves an agent_message as a user-role message, ensuring that assistant turns on either side remain separate.


Problem

The failure occurred in conversations involving subagents.

A typical sequence looked like this:

assistant reasoning A
agent_message from subagent
assistant reasoning B
assistant tool calls
tool results

Because the parser did not handle agent_message, the subagent message disappeared from the reconstructed conversation.

The resulting Anthropic request effectively became:

assistant:
  signed reasoning A
  signed reasoning B
  tool calls

user:
  tool results

This was not the original assistant response structure.

Anthropic therefore rejected the replayed request with an error similar to:

messages.19.content.23:
`thinking` or `redacted_thinking` blocks in the latest assistant
message cannot be modified. These blocks must remain as they were
in the original response.

The issue only appeared after a particular combination of events:

  1. The parent agent produced signed thinking.
  2. A subagent returned an asynchronous agent_message.
  3. The parent agent resumed with another signed thinking block.
  4. The parent agent issued one or more tool calls.
  5. Codex attempted to continue the existing conversation.

This made the failure appear to be an Anthropic provider, streaming, serialization, or tool-call issue, even though the actual corruption occurred earlier while parsing and rebuilding the conversation history.


Root cause

agent_message was a valid input item but had no dedicated handling in the Responses parser.

Since the item was skipped entirely, it could not act as a boundary between the assistant response before it and the assistant response after it.

The parser subsequently reconstructed adjacent assistant content as though it belonged to the same logical assistant turn.

That behavior is especially problematic for Anthropic thinking blocks because their signatures are tied to the original response structure. Their contents and placement cannot be modified during replay.

The thinking data itself was not invalid. The problem was that valid thinking blocks from separate responses were being placed into a newly constructed assistant message that never existed in the original conversation.


Fix

The parser now handles agent_message explicitly.

Its content is converted using the existing inputContentParts() normalization and stored as a user-role message:

if (effectiveType === "agent_message") {
  const agentMessage = item as {
    author?: string;
    recipient?: string;
    content?: unknown;
  };

  const content = inputContentParts(
    agentMessage.content as unknown[] | string | undefined,
  );

  const hasContent =
    typeof content === "string"
      ? content.trim().length > 0
      : content.length > 0;

  messages.push({
    role: "user",
    content: hasContent ? content : "(sub-agent message received)",
    timestamp: now,
  });

  continue;
}

Treating the message as external input to the parent agent preserves the required conversational structure:

assistant:
  signed reasoning A

user:
  subagent message

assistant:
  signed reasoning B
  tool calls

user:
  tool results

This ensures that the assistant response after the subagent message can still be assembled normally, while the earlier signed thinking block remains in its original assistant turn.


Why a user-role message is used

An agent_message is delivered to the parent agent rather than generated by the parent agent itself.

From the parent agent's perspective, it is new external input. Representing it as a user-role turn therefore provides the correct structural boundary without pretending that the message was part of either surrounding assistant response.

The primary requirement is not merely preserving the subagent text. It is preserving the separation between the two assistant turns.

Without that boundary, signed thinking blocks from different Anthropic responses can be merged again.


Regression test

A regression test was added for the exact sequence that previously caused the failure:

reasoning A
agent_message
reasoning B
function call
function call output

The test asserts that the parsed message roles are:

assistant
user
assistant
toolResult

It also verifies that:

  • The first reasoning block remains in the first assistant message.
  • The subagent result becomes a separate user message.
  • The second reasoning block and subsequent tool call remain together in the second assistant message.
  • The tool result remains paired with the correct tool call.
  • The two assistant reasoning blocks are never merged.

The regression test passes with:

1 pass
0 fail
8 expect() calls

Impact

This fixes conversation continuation failures for Anthropic models when subagent messages appear between signed assistant reasoning turns.

It prevents:

  • HTTP 400 responses caused by modified signed thinking blocks
  • Poisoned conversations that fail on every later continuation
  • Accidental merging of independent assistant responses
  • Loss of subagent output from reconstructed conversation history

The change is intentionally limited to parser behavior. No provider-specific workaround or mutation of Anthropic thinking blocks is required.


Validation

The fix was validated by:

  • Reproducing the original failing conversation
  • Capturing Anthropic’s exact upstream validation error
  • Confirming that the failure was caused by a modified latest assistant message
  • Adding explicit agent_message parsing
  • Successfully continuing the previously failing conversation
  • Adding and passing a dedicated regression test
  • Verifying the parser output preserves the required assistant/user/assistant boundary

Wibias added 2 commits July 11, 2026 07:57
…ted sub-agents receive spawn payloads

sanitizeEncryptedContentInPlace now runs on the raw body BEFORE parseRequest, for every path. Previously it ran only for native-bound models (no provider prefix) and only after parsing, so routed models (anthropic/*, opencode-go/*) built their parsed messages from the unsanitized input: the agent_message branch dropped the encrypted_content part carrying the actual task text, and spawned sub-agents received an empty NEW_TASK envelope. _rawBody shares the same object reference, so the native passthrough keeps the rewritten parts too; genuine Fernet ciphertext stays byte-identical (looksLikeBackendCiphertext). Adds a regression test mirroring the exact handleResponses order (sanitize, then parse).
@Wibias Wibias changed the title Preserve agent_message boundaries to prevent Anthropic signed-thinking replay failures fix: preserve agent_message boundaries to prevent Anthropic signed-thinking replay failures Jul 11, 2026
@lidge-jun

Copy link
Copy Markdown
Owner

Thanks! Cherry-picked onto dev as 8852e50 + 50aa116 (verbatim, authorship preserved) together with #94's delta; shipping in the next release. Closing since the commits landed via dev rather than a branch merge.

@lidge-jun lidge-jun closed this Jul 11, 2026
@Wibias
Wibias deleted the preserve-agent-message-boundaries branch July 13, 2026 19:18
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