Preserve OpenAI message phase and close out partial streams cleanly across providers - #271
Conversation
OpenAI labels each assistant output message with a phase (`commentary` for intermediate updates, `final_answer` for the answer) and asks that manually replayed history resend it unchanged; dropping it degrades gpt-5.3-codex and later. Dive discarded it in both the non-streaming and streaming decoders, so persisted history replayed through encodeMessages produced a structurally valid but lossy follow-up request, with no error identifying the missing field. Consumers relying on `previous_response_id` were unaffected; durable runtimes that persist and resend provider history were not. Carry the phase as `openai.phase` provider metadata on each decoded text block, reusing the existing ProviderMetadata replay channel rather than adding an OpenAI-shaped field to the model-neutral llm.Message. The phase attaches per text block rather than per turn because the encoder already emits one output message per block, so a response mixing commentary, a tool call, and a final answer replays each message under its own phase. Anthropic and Google strip or ignore metadata they do not own, so the state stays provider-local. Streaming treats `response.output_item.done` as authoritative, since OpenAI may omit the phase when the item is added and only label it on completion; the phase is also surfaced on content-block start when known early, so live consumers can distinguish commentary from the final answer as it streams. Dive never infers a phase: an unlabeled message stays unphased, and user messages never carry one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour. 📝 WalkthroughWalkthroughOpenAI assistant output phases are stored as ChangesOpenAI phase preservation
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The PR preserves OpenAI assistant message phases across decoding, persistence, streaming, and replay without changing other providers’ behavior; no actionable merge-blocking risk remains beyond normal checks and review. Sequence Diagram(s)sequenceDiagram
participant OpenAIResponses
participant OpenAIDecoder
participant Message
participant OpenAIEncoder
OpenAIResponses->>OpenAIDecoder: assistant output with phase
OpenAIDecoder->>Message: text block with openai.phase metadata
Message->>OpenAIEncoder: serialized assistant history
OpenAIEncoder->>OpenAIResponses: replayed message with phase
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
The test previously asked a plain question, which only ever produces a final_answer message, so the commentary half of the acceptance criteria went unexercised. Give the model a tool and a prompt that invites a preamble: live gpt-5.6-sol, -terra, and -luna all return a commentary message alongside their tool calls. The turn is now persisted, reloaded, replayed with tool results on a stateless follow-up, and asserted to carry both commentary and final_answer. Logs report the phases each turn actually returned so a qualification run shows its work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Live qualification run — done. The last acceptance criterion is now verified rather than just written. I first ran the test as originally committed and it passed, but the log showed it had only exercised Reworked it to give the model a tool and a prompt that invites a preamble, which is where The flow now qualifies the real path end to end: the model returns a I also confirmed all three variants the downstream unblock covers emit Every acceptance criterion is now verified, live included. Full suite green across all modules. |
Code review of the phase work found the metadata delta landing after the block it belongs to was already closed: OpenAI sends output_text.done before output_item.done, so the stop was emitted first and the late phase label arrived against a finished block. It happened to survive only because llm.ResponseAccumulator never closes blocks — a consumer that rebuilds messages from the event stream and honors content_block_stop, exactly the manual-replay runtime this branch targets, would finalize the text and drop the phase again. Close a message item's text blocks on its own done event instead, after any metadata that event contributed, matching how reasoning and function_call blocks already close. Emit the phase delta only when the block's start event could not announce it, so a message labeled up front no longer re-sends its phase. A response that ends without output_item.done closes its open text blocks before message_stop, so the lifecycle stays balanced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
reduceBlock rebuilt a shrunken text block with its CacheControl and Citations but not its Metadata, so an oversized block that compaction truncated lost the replay state the provider needs on the next request — a Google function-call thought signature today, and the OpenAI message phase this branch adds. tool_use blocks lost theirs the same way when their input was culled. Carry the metadata across on both, cloned so the reduced block still owns its own copy and the original stays untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
A response that ends without an item's done event (response.incomplete, or response.completed arriving early) previously closed only its text blocks, and did so after message_delta. Generalize the close to reasoning and function-call blocks and emit the stops before message_delta so the event order matches the Google and Anthropic iterators. Also repair the mangled doc comment on the close helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
The openaicompletions iterator (used by Mistral and OpenRouter) emitted no content_block_stop, message_delta, or message_stop when a stream reached [DONE] or EOF without a finish_reason chunk, leaving the accumulated message open. Close open text and tool-call blocks and synthesize the terminal events exactly once at either end marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
Gemini streams terminate on iterator exhaustion rather than on a finish reason, and queueFinalEvents already closes the open thinking or text block before message_delta, so no behavior change is needed. Lock that in with the same dangling-block test the OpenAI iterators now carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
This started as a fix for one reported bug — OpenAI's
phaselabel on assistant messages was being dropped — and grew to cover a few closely related streaming problems the fix exposed. Everything here is about making sure what a model sends us survives the round trip intact: into memory, into storage, and back out on the next request or to whoever is watching the stream.1. OpenAI's message "phase" is no longer lost
When a model on OpenAI's Responses API replies, it can label each message as
commentary("checking that now…") orfinal_answer. OpenAI's SDK docs say to send those labels back on follow-up requests and warn that dropping them can make the model perform worse. We were dropping them.I reproduced that first — the report's non-streaming test failed on
mainexactly as described. The fix keeps the label on each text block as a small piece of provider metadata (openai.phase), using the same channel that already carries OpenAI reasoning state and Google thought signatures. It's read when we decode a response, written back when we replay history, and tracked while streaming.Two design notes:
Phasefield on the sharedllm.Messagetype. That's enough because we already send one output message per text block when replaying, so a commentary message, a tool call, and a final answer go back out as three separate messages, each with its own label. It also keeps an OpenAI-specific idea out of the provider-neutral core.2. Streaming delivers the phase while the block is still open
OpenAI sometimes only tells us the phase at the end of an output item, after the text has finished streaming. Previously we had already closed the text block by then, so the label arrived too late for anyone listening to the stream. Now a text block stays open until its item is done, the phase is announced inside the block's lifetime, and we don't announce it twice if it was already known up front.
3. Streams that end early now close out cleanly — every provider
Pulling on that thread showed that a stream which ends abruptly — OpenAI sending
response.incomplete, or a connection simply stopping — could leave blocks "open" with no closing event, or close them in the wrong order. Anyone consuming the stream then sees an unbalanced sequence. I audited every provider:message_delta, which is the order the other providers already use.[DONE]or end-of-input without afinish_reasonused to emit nothing at all to finish the message — no closing events, nomessage_delta, nomessage_stop. It now closes open text and tool-call blocks and ends the message properly, exactly once.4. Compaction keeps provider metadata
When compaction shrinks an oversized text or
tool_useblock, it used to rebuild the block without its metadata — silently throwing away things like the phase label above or a Google thought signature. It now keeps them.Testing
providers/openai/phase_test.go— 11 cases covering decode → persist → encode for both phases, streaming, a mixed commentary/tool-call/final-answer response,Copy, unlabeled messages, and making sure nothing leaks into other providers. Confirmed these fail without the fix rather than assuming.TestStreamIteratorClosesDanglingBlocksin the OpenAI, chat-completions, and Google stream iterator tests — one block start, one block stop, the stop beforemessage_delta,message_stoplast, and the partial content still arriving as a well-formed message. The OpenAI and chat-completions versions fail without their fixes; the Google one passes unchanged.llm/message_test.go.provider_integration_test.gogains a live multi-turn check against GPT-5.6 (-tags integration, needsOPENAI_API_KEY) that persists a real turn, reloads it, and confirms OpenAI accepts the replayed history. I have not run this one against the live API — it needs a key and a billed call — so please run it before relying on it.All test suites are green across every module (root,
providers/openai,providers/google,providers/grok), plusgo vetandgofmt. One heads-up:TestToolUseinopenaicompletionsis a live call against gpt-4o that occasionally answers with text instead of a tool call; it flaked once during my runs and passed 3/3 on retry. It doesn't touch any code in this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01F1eYPGBnNesvpcJiSAouxm
Summary by CodeRabbit
New Features
commentaryandfinal_answer, across responses, streaming, persistence, and follow-up requests.Bug Fixes
Tests