diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 365f664264..e9c466a45f 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -527,7 +527,7 @@ function buildResponseJSONWithBudget( compactionEncryptedContent = e.compactionEncryptedContent; sawTerminal = true; endTurn = e.endTurn; - cleanDone = e.stopReason === undefined; + cleanDone = !isTruncatedStopReason(e.stopReason); rawStopReason = e.stopReason; if (e.providerState) options?.onProviderState?.(e.providerState); // Match streaming: max_tokens and content_filter both terminate as incomplete. diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index db0adbdb7c..eda83f01f7 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1171,7 +1171,7 @@ export function bridgeToResponsesSSE( break; } case "done": { - if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer"); + if (currentMsg) closeCurrentMessage(isTruncatedStopReason(event.stopReason) ? undefined : "final_answer"); if (currentReasoning) closeCurrentReasoning(); if (currentRawReasoning) closeCurrentRawReasoning(); flushHiddenRawReasoning(); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 94dc919bbe..dc72ce0172 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -646,9 +646,11 @@ request-log accounting without promoting a truncated repair candidate. Chat Completions streams do not carry the Responses `message.phase` field. The bridge keeps an unphased live message provisional while its deltas arrive, then assigns `commentary` when a later tool, search, reasoning, or assistant boundary proves that more work follows, and assigns -`final_answer` only when a clean terminal `done` closes the current message. Explicit adapter -phases always win. Streaming `output_item.added` remains unphased until that future boundary is -known; `output_item.done` and the terminal response snapshot carry the authoritative inferred phase +`final_answer` when a terminal `done` closes the current message unless the shared stop-reason +classifier marks that reason as truncated. Normal provider reasons such as `end_turn`, +`stop_sequence`, and `tool_use` therefore remain final answers, as does an absent reason. Explicit +adapter phases always win. Streaming `output_item.added` remains unphased until that future boundary +is known; `output_item.done` and the terminal response snapshot carry the authoritative inferred phase with the same item id. The batch/non-streaming bridge follows the same rule. > Decision record: [ADR-0069](../decisions/ADR-0069-chat-to-responses-message-phase-inference.md) diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index d8b4d91c64..c8026cc1b2 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -1344,6 +1344,60 @@ describe("citation markers never reach the client (#3150)", () => { }); }); +describe("terminal stop classification preserves final answer phases (#4855)", () => { + test.each([ + ["end_turn", { type: "done", stopReason: "end_turn" }, "response.completed", "final_answer", undefined], + ["max_output_tokens", { type: "done", stopReason: "max_output_tokens" }, "response.incomplete", undefined, "max_output_tokens"], + ["refusal", { type: "done", stopReason: "refusal" }, "response.incomplete", undefined, "content_filter"], + ["an absent stopReason", { type: "done" }, "response.completed", "final_answer", undefined], + ] as const)("streaming terminal %s classifies the final message phase", async ( + _label, + terminal, + terminalEvent, + expectedPhase, + expectedIncompleteReason, + ) => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "text_delta", text: "answer" }, + terminal, + ]), "routed/model")); + const message = frames.find(frame => + frame.event === "response.output_item.done" + && (frame.data.item as Record)?.type === "message" + )?.data.item as Record; + const response = frames.find(frame => frame.event === terminalEvent)?.data.response as Record; + + expect(message.phase).toBe(expectedPhase); + expect((response.output as Record[])[0]?.phase).toBe(expectedPhase); + expect((response.incomplete_details as Record | undefined)?.reason) + .toBe(expectedIncompleteReason); + }); + + test.each([ + ["end_turn", { type: "done", stopReason: "end_turn" }, "completed", "final_answer", undefined], + ["max_output_tokens", { type: "done", stopReason: "max_output_tokens" }, "incomplete", undefined, "max_output_tokens"], + ["refusal", { type: "done", stopReason: "refusal" }, "incomplete", undefined, "content_filter"], + ["an absent stopReason", { type: "done" }, "completed", "final_answer", undefined], + ] as const)("buffered terminal %s classifies the final message phase", ( + _label, + terminal, + expectedStatus, + expectedPhase, + expectedIncompleteReason, + ) => { + const response = buildResponseJSON([ + { type: "text_delta", text: "answer" }, + terminal, + ], "routed/model"); + const message = (response.output as Record[])[0]; + + expect(response.status).toBe(expectedStatus); + expect(message?.phase).toBe(expectedPhase); + expect((response.incomplete_details as Record | undefined)?.reason) + .toBe(expectedIncompleteReason); + }); +}); + describe("Responses bridge stopReason threading (issue #246)", () => { test("done with stopReason max_tokens emits response.incomplete", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([