Repository navigation
Description
Summary
The "every tool call has an output" invariant is maintained only when history is rebuilt and is never checked at the point a request is sent, so a function_call with no matching function_call_output can reach a strict Responses upstream. That is the failure that permanently wedges a thread: the upstream rejects the whole turn with No tool output found for tool call X, and the rejected turn stays in the loaded history, so every later turn reproduces it until the thread is unloaded.
I am filing this as a location report rather than another reproduction of the symptom — the symptom is already tracked in #44604, #31053, #36827, #31579. What may be new here is where the guard is missing, plus a runnable checker for the strict-upstream rules, which is what took me the longest to establish.
Where the invariant is (and is not) enforced
codex-rs/core/src/context_manager/normalize.rs:21ensure_call_outputs_present()synthesizes an output for any dangling call (the placeholder text isaborted).- It has exactly one call site:
codex-rs/core/src/context_manager/history.rs:798, insidenormalize_history(). So it applies to the history-snapshot path only. codex-rs/core/src/client.rs:849build_responses_request()performs no pairing/structure validation on the assembledinput.- The first sampling request of a turn does not go through the snapshot path at all:
codex-rs/core/src/session/turn.rs:1585usesinitial_input.take(), i.e. the caller-provided input, and only later iterations callclone_history().for_prompt(...).
So there is no send-time barrier. Whether the wire payload is well-formed depends on which path produced it, and a normal reply (not an error) is a well-formed provider response, which is why the failure surfaces as a wedged thread rather than a retry.
The injected-output case is a separate, deliberate orphan
Automations and cross-thread delegation submit SubmittedTurnInput::ResponseItem(FunctionCallOutput { call_id: None, .. }) (codex-rs/core/src/session/turn.rs:739). That shape is intentional — it is treated as external context (#39782, #39791) — but by construction it is an output with no call. On a strict upstream, neither form is acceptable:
input[i] sent to https://api.deepseek.com/v1/responses |
result |
|---|---|
function_call_output with name/namespace/output, no call_id |
400 input: missing field 'call_id' |
same item with a synthesized call_id, no matching function_call |
400 No tool call found for tool output with call_id … |
synthesized function_call + the original output, one shared call_id |
200 |
the output rewritten as a developer message |
200 |
Populating call_id alone therefore converts one 400 into another; the pair has to exist in the same request. This is the same conclusion as #42088 and #45450, and I have added the table there as well.
A detail that cost me a lot of time
While mapping strict-upstream behaviour I found the provider's error text is not a reliable classifier, which may matter for triage of these reports:
| transcript sent | verdict | message |
|---|---|---|
single call: call → output → message(hook) |
200 | |
single call: call → message(hook) → output |
400 | No tool output found for tool call A. |
batch: callA → callB → outA → outB |
200 | |
batch split by its own outputs: callA → outA → callB → outB |
400 | The 'reasoning_text' in the thinking mode must be passed back to the API |
| any transcript with an assistant/tool item but no reasoning item | 400 | The 'reasoning_text' … |
reasoning replayed as summary with empty content |
400 | The 'reasoning_text' … |
The third and fourth rows are the same underlying ordering problem reported with two different messages; the "reasoning" wording sends you after the wrong subsystem (I spent a cycle on it). Note also that batch-integrity is only decidable when a reasoning item is present — A → outA → B → outB is locally legal for every pair, so a checker cannot reject it on item order alone.
Runnable checker
To make this independent of any local proxy, I wrote a small strict-upstream stub that implements exactly the rules in the table above and returns the same messages. It reproduces all nine verdicts I measured against the real endpoint:
python3 strict_upstream.py --port 8123 --log /tmp/strict.jsonl --mode batch
# then point a CODEX_HOME provider at http://127.0.0.1:8123/v1 and start a turnIt logs every request with a compact shape summary and the verdict, so a failing case can be pasted directly into a report. Attached: strict_upstream.py — single file, no dependencies beyond the standard library.
Possible directions (for discussion, not a PR)
I understand external code PRs are not accepted, so this is only meant to frame the problem:
- Apply the presence invariant at the send boundary (or normalize
initial_inputthe same way the snapshot path is normalized), so no request can leave with a dangling call regardless of which path built it. - Decide the intended wire representation for injected standalone outputs for providers that verify pairing — synthesize the matching
function_call, or lower the item to a message — rather than leaving it to each client/proxy to guess. - Treat the "dangling call" 400 as recoverable (repair the loaded history and retry once) instead of ending the turn, since ending the turn is what makes the thread permanently unusable.
Environment
macOS arm64, Codex Desktop / app-server 0.154.0-alpha.6.2, upstream https://api.deepseek.com via a local provider gateway; code locations quoted against origin/main as of e269f21.
Activity
github-actions commentedon Sep 17, 2026
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
function_call_outputwithoutcall_idcause request rejection #45318Powered by Codex Action
JunkaiWang-TheoPhy commentedon Sep 17, 2026
Adding the run that triggered this today, because it comes with a roll-out comparison that the summary above does not have.
The persisted roll-out for that thread contains 79
function_callitems and 79 outputs, and no unpaired call — while thecall_idnamed in the error appears neither as a call nor as an output in it. Every other call in that thread uses the ordinarycall_00_…form; only the failing one usescall_dGjPby…. So the rejected call was present in the request that went out and absent from disk, which is consistent with the send-boundary gap described here rather than with "the output arrived late".Two supporting measurements from a local build of a validating proxy, checked against the real endpoint, that may save time for anyone reproducing:
callA → outA → callB → outB) is rejected withThe 'reasoning_text' in the thinking mode must be passed back to the API. The cause is ordering, not reasoning; reordering tocallA → callB → outA → outBreturns 200.call_idon a standalonefunction_call_outputis not sufficient on its own: with no matchingfunction_callthe same request fails differently (No tool call found for tool output with call_id …). Only synthesizing the pair (or rewriting the item as a message) yields 200.I filed the same observation twice from two parallel sessions (#46195, now closed) — sorry for the duplicate; this issue is the right place for it.