Repository navigation
Anthropic clean end_turn is treated as a non-clean stop, so the final assistant message loses phase: "final_answer" #4855
Description
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) // classifiedReproduction
- Route an Anthropic model through OpenCodex to Codex App using the native Anthropic adapter.
- Run any turn that completes normally and produces a final answer.
- 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
lidge-jun commentedon Sep 17, 2026
리뷰 · 우선순위 77 / 80
이 이슈는 Anthropic이 정상 턴을
stop_reason: "end_turn"로 끝낼 때, Responses 브리지가 그 문자열을 “깨끗한 종료”가 아니라 “뭔가 있는 종료”로 잘못 읽어서 마지막 어시스턴트 메시지에phase: "final_answer"를 안 붙인다는 보고입니다. 지금devHEAD는19bdcaaf6(패키지 2.58.0)이고, 지적한 줄은 그대로입니다.src/bridge/sse.ts1174는event.stopReason이 있으면 아무 phase도 안 주고, 없을 때만"final_answer"를 줍니다. Anthropic 어댑터(src/adapters/anthropic.ts1150·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.ts530도 같은 구멍입니다.cleanDone = e.stopReason === undefined라서 Anthropic 성공 턴은 항상cleanDone === false가 되고, 아래 545의final_answerflush가 안 됩니다. 반면 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"가 Anthropicend_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로 닫힌다고 봄 — 그 가정을 테스트로 잠글지)너의 추천
재현·원인 줄·제안 diff가 모두 현재
dev와 일치하는 고품질 버그 리포트다.sse.ts1174와response-json.ts530을isTruncatedStopReason기준으로 맞추고, Anthropicend_turn→final_answer/ truncated stop → phase 없음 회귀를 넣은 좁은 PR을dev에 랜딩하는 쪽을 추천한다. 우선순위는 스트리밍/브리지 버그 중에서도 높다.이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Sep 17, 2026
Fixed on
devby #4885 (27e4310077), released in 2.58.0.Both emit paths now use
isTruncatedStopReason—src/bridge/sse.tsand the non-streaming JSON path — so a normalend_turnis no longer judged by raw truthiness andfinal_answersurvives. No truthiness check remains ondev. The command-code surface is covered by the same change.