Skip to content

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

Closed

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

  1. added
    bugSomething isn't working
    streamingSSE, WebSocket, terminal stream frames
    on Sep 17, 2026
  2. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 77 / 80

    이 이슈는 Anthropic이 정상 턴을 stop_reason: "end_turn"로 끝낼 때, Responses 브리지가 그 문자열을 “깨끗한 종료”가 아니라 “뭔가 있는 종료”로 잘못 읽어서 마지막 어시스턴트 메시지에 phase: "final_answer"를 안 붙인다는 보고입니다. 지금 dev HEAD는 19bdcaaf6(패키지 2.58.0)이고, 지적한 줄은 그대로입니다. src/bridge/sse.ts 1174는 event.stopReason이 있으면 아무 phase도 안 주고, 없을 때만 "final_answer"를 줍니다. Anthropic 어댑터(src/adapters/anthropic.ts 1150·1333·1461 근처)는 성공 턴에서도 stop_reason을 그대로 stopReason으로 넘기므로, "end_turn"은 항상 truthy입니다. 그래서 Codex App에서는 commentary 구간 구분선은 보이는데, 마지막 답 구간의 “Worked for …” 구분선이 Anthropic 라우트에서만 빠집니다.

    같은 case "done": 블록 안을 보면 이미 분류기를 쓰는 이웃이 세 군데 있습니다. 1179·1184·1196은 모두 isTruncatedStopReason(event.stopReason)을 봅니다. 그런데 phase를 정하는 1174만 raw truthiness입니다. src/responses/truncated-stop-reason.ts 주석도 “Anthropic은 stop_reason을 그대로 넘긴다”고 적고, end_turn은 잘린 종료 목록에 없습니다. 즉 "end_turn" / "stop_sequence" / "tool_use"는 깨끗한 완료로 남아야 하고, refusal / pause_turn / max_output_tokens / model_context_window_exceeded만 잘림으로 가야 합니다. 비스트리밍 쪽 src/bridge/response-json.ts 530도 같은 구멍입니다. cleanDone = e.stopReason === undefined라서 Anthropic 성공 턴은 항상 cleanDone === false가 되고, 아래 545의 final_answer flush가 안 됩니다. 반면 538·558은 이미 truncationReasonFor / isTruncatedStopReason을 씁니다.

    이 결함 클래스는 예전에 openai-chat 쪽에서 #542로 다룬 것과 같습니다. 그때 규칙은 “깨끗한 터미널 done이면 지금 텍스트를 final_answer로 닫는다”였는데, Anthropic은 성공 턴에도 절대 “빈 stopReason”을 안 만들어서 그 규칙이 한 번도 안 탑니다. 고치는 자리는 제안대로 좁습니다. 1174와 530을 isTruncatedStopReason 기준으로 바꾸면 됩니다. 두 파일 모두 이미 그 함수를 import 하고 있어서 새 결합이 없습니다. 어댑터를 openai-chat처럼 정규화하는 방법도 있지만, 그러면 앞으로 어댑터마다 같은 분기가 다시 생깁니다. 브리지에서 분류하면 command-code처럼 raw finish reason("stop")을 넘기는 경로도 같이 덮입니다. types.ts/config.ts 대형 분리와는 무관하고, 브리지 두 줄 + 회귀 테스트 수준이라 리베이스 부담도 작습니다.

    라인 1174 (src/bridge/sse.ts) - event.stopReason ? undefined : "final_answer"가 Anthropic end_turn을 non-clean으로 취급해 phase를 빠뜨린다.
    라인 530 (src/bridge/response-json.ts) - cleanDone = e.stopReason === undefined가 같은 이유로 비스트리밍 final_answer를 막는다.
    경로 src/responses/truncated-stop-reason.ts - end_turn은 TRUNCATED 목록에 없고, 주석도 “모르는 값은 잘림이 아니다”라고 이미 정해 두었다. 분류기를 phase에도 쓰면 이 문서와 맞는다.
    경로 src/adapters/anthropic.ts (1150·1333·1461) - 성공 턴에서도 stopReason을 그대로 실어 보낸다. 어댑터를 바꾸지 않아도 브리지 수정으로 충분하다.
    경로 회귀 테스트 - Anthropic done + stopReason: "end_turn"일 때 터미널 메시지 phase가 final_answer인지, refusal/max_output_tokens일 때는 phase를 비우거나 incomplete로 가는지 고정할 테스트가 없다.

    메인테이너의 판단이 필요한 지점

    • 브리지에서 isTruncatedStopReason으로 통일할지, Anthropic 어댑터에서 openai-chat처럼 stopReason을 비우는 정규화를 할지(이슈 제안은 브리지 쪽)
    • tool_use로 done이 올 때 open message가 남는 경로가 실제로 있는지(이슈는 tool_call_start에서 commentary로 닫힌다고 봄 — 그 가정을 테스트로 잠글지)
    • 2.57.x 핫픽스인지 2.58.0 트레인만인지(증상은 npm latest 2.57.0 / Codex App에서 사용자에게 바로 보임)

    너의 추천
    재현·원인 줄·제안 diff가 모두 현재 dev와 일치하는 고품질 버그 리포트다. sse.ts 1174와 response-json.ts 530을 isTruncatedStopReason 기준으로 맞추고, Anthropic end_turn → final_answer / truncated stop → phase 없음 회귀를 넣은 좁은 PR을 dev에 랜딩하는 쪽을 추천한다. 우선순위는 스트리밍/브리지 버그 중에서도 높다.

    이 댓글은 grok-bot이 작성했습니다

  3. lidge-jun commented on Sep 17, 2026

    @lidge-jun
    Owner

    Fixed on dev by #4885 (27e4310077), released in 2.58.0.

    Both emit paths now use isTruncatedStopReason — src/bridge/sse.ts and the non-streaming JSON path — so a normal end_turn is no longer judged by raw truthiness and final_answer survives. No truthiness check remains on dev. The command-code surface is covered by the same change.

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