Skip to content

Anthropic clean end_turn is treated as a non-clean stop, so the final assistant message loses phase: "final_answer" #4855

Description

@nostitos

Client or integration

Codex App (desktop)

Area

Provider adapter / Responses bridge

Summary

Anthropic ends a normal turn with stop_reason: "end_turn", and the adapter forwards that value verbatim on the done event. The Responses bridge then decides the terminal message phase from the raw truthiness of stopReason:

// src/bridge/sse.ts:1174
if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");

Because "end_turn" is truthy, the terminal assistant message is emitted without a phase. Every successful Anthropic-routed turn therefore ends with no final-answer marker, even though the commentary labels assigned at tool and search boundaries are correct.

This is the same defect class fixed in #542 for openai-chat. That fix established the rule that "a clean terminal done finalizes the current text as final_answer", but the Anthropic adapter never emits a clean done for a successful turn, so the rule never fires for it.

The inconsistency is local to one switch case. Four decisions in case "done": read event.stopReason, and three of them already classify it instead of testing truthiness:

1174  if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");   // raw truthiness
1179  if (isTruncatedStopReason(event.stopReason)) failCurrentToolCall();
1184  if (currentWebSearch) closeCurrentWebSearch(isTruncatedStopReason(event.stopReason) ? "failed" : "completed", []);
1196  if (options?.compaction && !isTruncatedStopReason(event.stopReason)) {

src/responses/truncated-stop-reason.ts already documents the underlying hazard: adapters disagree on vocabulary, and "Anthropic forwards stop_reason verbatim". Line 1174 is the one decision in that block that does not use the classifier built for exactly this.

The non-streaming path splits the same way:

// src/bridge/response-json.ts
530:  cleanDone = e.stopReason === undefined;                  // raw
538:  const truncation = truncationReasonFor(e.stopReason);    // classified
558:  isTruncatedStopReason(rawStopReason)                     // classified

Reproduction

  1. Route an Anthropic model through OpenCodex to Codex App using the native Anthropic adapter.
  2. Run any turn that completes normally and produces a final answer.
  3. Inspect the persisted assistant message items for that turn.

Observed: the terminal assistant message carries no phase. Intermediate messages correctly carry phase: "commentary".

Expected: the terminal assistant message carries phase: "final_answer", as native Responses output does.

User-visible effect in Codex App: the turn renders without the "Worked for …" divider that separates turn activity from the final answer. Native OpenAI turns in the same thread render it normally.

Version

@bitkyc08/opencodex 2.56.0 installed. Verified unchanged on main at 2.57.0: src/adapters/anthropic.ts lines 1150, 1333 and 1461; src/bridge/sse.ts line 1174; src/bridge/response-json.ts line 530.

Operating system

macOS 26.6.2 (25G83), Node v20.19.5

Provider and model

Native Anthropic adapter over the subscription OAuth route. Reproduced on two Anthropic models (Opus and Fable). Not reproducible on native OpenAI models in the same app build.

Logs or error output

Three consecutive turns in a single Codex thread, with only the model changed between them. Message phases as persisted:

turn 1   main/gpt-6-astra           6 x commentary  ->  final_answer
turn 2   main/gpt-6-astra           2 x commentary  ->  final_answer
turn 3   anthropic/claude-opus-5   14 x commentary  ->  (no phase)

A separate thread on anthropic/claude-fable-5-1 shows the same shape: 23 commentary messages followed by a terminal message with no phase.

Suggested fix

Use the existing classifier for the phase decision, matching the three neighbouring lines:

  // src/bridge/sse.ts:1174
- if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");
+ if (currentMsg) closeCurrentMessage(isTruncatedStopReason(event.stopReason) ? undefined : "final_answer");
  // src/bridge/response-json.ts:530
- cleanDone = e.stopReason === undefined;
+ cleanDone = !isTruncatedStopReason(e.stopReason);

isTruncatedStopReason is already imported in both files, so no new coupling is introduced.

Truncation semantics are preserved. TRUNCATED_STOP_REASONS already maps Anthropic's refusal, pause_turn, max_output_tokens and model_context_window_exceeded, and the adapter converts error and content-filter terminals into error/incomplete events before this path. The remaining reachable values are end_turn, stop_sequence and tool_use, all of which are clean completions. tool_use cannot leave an open message to mislabel, because case "tool_call_start" closes the current message as commentary at line 1011.

Fixing the bridge rather than the adapter also covers command-code, which forwards raw AI SDK finish reasons and so returns a truthy "stop" on clean turns. The alternative is to normalize inside the Anthropic adapter the way openai-chat does with stopReasonFor and google does inline, but that leaves each future adapter free to reintroduce the same divergence.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions