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
- 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
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 thedoneevent. The Responses bridge then decides the terminal message phase from the raw truthiness ofstopReason: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 thecommentarylabels 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 terminaldonefinalizes the current text asfinal_answer", but the Anthropic adapter never emits a cleandonefor a successful turn, so the rule never fires for it.The inconsistency is local to one switch case. Four decisions in
case "done":readevent.stopReason, and three of them already classify it instead of testing truthiness:src/responses/truncated-stop-reason.tsalready documents the underlying hazard: adapters disagree on vocabulary, and "Anthropic forwardsstop_reasonverbatim". 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:
Reproduction
Observed: the terminal assistant message carries no
phase. Intermediate messages correctly carryphase: "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/opencodex2.56.0 installed. Verified unchanged onmainat 2.57.0:src/adapters/anthropic.tslines 1150, 1333 and 1461;src/bridge/sse.tsline 1174;src/bridge/response-json.tsline 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:
A separate thread on
anthropic/claude-fable-5-1shows the same shape: 23commentarymessages followed by a terminal message with no phase.Suggested fix
Use the existing classifier for the phase decision, matching the three neighbouring lines:
isTruncatedStopReasonis already imported in both files, so no new coupling is introduced.Truncation semantics are preserved.
TRUNCATED_STOP_REASONSalready maps Anthropic'srefusal,pause_turn,max_output_tokensandmodel_context_window_exceeded, and the adapter convertserrorand content-filter terminals intoerror/incompleteevents before this path. The remaining reachable values areend_turn,stop_sequenceandtool_use, all of which are clean completions.tool_usecannot leave an open message to mislabel, becausecase "tool_call_start"closes the current message ascommentaryat 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 wayopenai-chatdoes withstopReasonForandgoogledoes inline, but that leaves each future adapter free to reintroduce the same divergence.Checks