fix(streaming): settle recovered final answers over stale no-response sidecars - #6640
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42c0e81a0e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| has_authoritative_turn = bool( | ||
| isinstance(active_turn_identity, dict) | ||
| and active_turn_identity.get('token') | ||
| ) | ||
| display = prefix if has_authoritative_turn else prefix + [pending_user] |
There was a problem hiding this comment.
Verify that the matched tail owns the active-turn token
When a user retries the same prompt after an earlier user -> partial -> no_response turn and the new soft-partial result repeats the same assistant text, _split_stale_no_response_tail() matches the old turn by text alone. Every stream has a nonempty identity token, so this check then treats the unrelated old tail as authoritative and removes it, permanently collapsing two turns into one. Require the matched pending_user itself to carry the active-turn token or another exact ownership proof before retiring the tail.
AGENTS.md reference: AGENTS.md:L85-L91
Useful? React with 👍 / 👎.
| # stale. Remove only that just-created backup after the | ||
| # repaired live file was durably saved. | ||
| try: | ||
| s.path.with_suffix('.json.bak').unlink(missing_ok=True) |
There was a problem hiding this comment.
Delete only the backup created by this repair
When the repaired transcript was assembled from additional state.db rows but the live JSON already has fewer messages than the repaired result, s.save() performs a growth save and therefore does not create or replace .json.bak. This unconditional unlink then deletes any pre-existing backup from an earlier shrink, potentially destroying the only recoverable copy of older history. Record whether this save actually produced the stale-tail backup, or verify that backup's contents before removing it.
AGENTS.md reference: AGENTS.md:L92-L97
Useful? React with 👍 / 👎.
nesquena-hermes
left a comment
There was a problem hiding this comment.
Blocking: content equality is not current-turn ownership
The new recovery path can delete an older completed assistant turn when its text happens to match the current recovered answer.
_retire_recovered_final_answer_sidecar() removes prefix[-1] when _is_matching_completed_assistant() sees the same text plus either _db_persisted or finish_reason == "stop". Neither marker ties that row to the active turn. _db_persisted only means that the message was durably flushed, so normal historical assistant rows carry it too. The active-turn token is checked only as a boolean and is never matched to the candidate being removed.
I reproduced the loss at exact head 42c0e81a0e2b with a reviewer-owned sandboxed regression:
- Start with an older complete exchange whose persisted assistant answer is
Done. - Append the current
user -> _partial assistant("Done") -> generic no-response errortail. - Set the recovered partial result to assistant
Doneand provide an active-turn token. - Call the new retirement helper.
Expected: the older [user, assistant] exchange remains intact while only the stale current-turn tail is retired.
Actual: the returned display contains only the older user row. The older completed assistant was popped. The probe failed 1 failed in 3.79s inside warmup-safe-test.sh after a CLEAN threat scan and Layer-3 sandbox entry.
Required fix
- Do not use answer text plus
_db_persisted/finish_reasonas authority to delete a pre-tail assistant row. - Require durable provenance tying the candidate to this exact turn/result, preferably a stable state-db message ID or turn ID.
get_state_db_session_messages()already selectsid, but its projection currently drops it before returning messages. - Fail closed when provenance is absent. Preserving a possible duplicate is safer than deleting historical transcript data.
- Add a regression with a complete older
[user, assistant]exchange whose assistant has the same text as the recovered current answer and an older identity/timestamp. Assert both older rows survive. Keep a separate positive fixture where the duplicate candidate is provably owned by the current turn.
The contributed test file passes (29 passed), as do targeted recovery/retry neighbors (37 passed, 9 deselected), but none covers this same-text multi-turn collision.
Thinking Path
no_responsemeans no usable assistant final content was produced.no_response#5575 documented a recovery variant where durable state already contains the completed answer while the WebUI sidecar still ends with a matching_partialrow and the generated genericNo response from providercard.master, that stale sidecar can be reconstructed as prior model context. An assistant-only recovered result is then treated as replay, the current-user boundary is misplaced, and settlement emits another generic error instead ofdone.What Changed
api/streaming.pythat recognizes only the generated generic no-response sidecar.context_messagesbefore the existing_settle_result_messages(...)chokepoint runs._active_turn_identityowner when available, preventing duplicate user rows.tool_callsor_partial_tool_calls..json.bakcreated by this positively identified intentional shrink, after the repaired live sidecar is durably saved, so generic startup recovery cannot resurrect the stale card.Why It Matters
A successful long-running turn can otherwise end with a complete answer followed by a misleading rate-limit-style error. Besides confusing the user, the stale visible sidecar can be fed back into model context and repeat the failure on recovery. This change restores one coherent current-turn transcript and keeps
no_responsereserved for turns that truly have no usable final answer.Contract Routing
Task type: narrow runtime bug fix
Touched state layers: visible transcript,
context_messages, active-turn ownership, settlement terminal state, shrink backupRelevant public docs:
AGENTS.mdCONTRIBUTING.mddocs/CONTRACTS.mddocs/rfcs/webui-run-state-consistency-contract.mddocs/rfcs/live-to-final-assistant-replies.mdPreserved invariants:
This restores the existing accepted contract; it does not change the contract or wire protocol.
Verification
Regression bite on unmodified
master(dd7f6ac3)The exact end-to-end regression failed before the fix:
Patched branch
The full repository suite and GitHub CI are expected to provide the broader Python 3.11–3.13 matrix.
Risks / Follow-ups
Release-note wording
A recovered completed answer is no longer followed by a misleading
No response from providercard when a stale partial/error sidecar survives recovery. Real provider, authentication, cancellation, tool-limit, compression, and captured HTTP failures remain visible.Model Used
gpt-5.6-solvia Hermes Agent / LiteLLMRefs #5575.