Skip to content

Standalone injected function_call_output is rejected by strict Responses providers; adding a call_id is not a sufficient repair #45450

Open

Description

@JunkaiWang-TheoPhy

Summary

Injected standalone function_call_output items (automations, heartbeat ticks, send_message_to_thread / create_thread delegation) carry id / name / namespace / output but no call_id and no matching function_call. Codex supports this shape deliberately (it is treated as external context since #39782 / #39791), and OpenAI-compatible upstreams accept it — but strict Responses providers reject the entire request at the wire layer, and the injected item is persisted into the rollout, so the target thread is bricked from then on. This is the same failure family as #41690, #42088, #45227, #44723, #44519, #42067.

The gap I want to report is not "the field is missing" (that is known) but which repair actually works at the wire level, because the obvious one does not.

Wire-level probes (macOS, app-server 0.154.0-alpha.6.2, provider https://api.deepseek.com, wire_api = "responses")

body sent to /responses result
function_call_output with name + namespace, no call_id 400 Failed to deserialize the JSON body into the target type: input: missing field 'call_id'
same item with a synthesized call_id, but still no function_call 400 No tool call found for tool output with call_id ...
synthesized function_call + the original output, sharing one call_id 200
the output rewritten into a developer/user message 200

So: assigning a call_id to the orphan is not a fix; the provider requires the pair to exist in the same request. Any proposed normalizer needs to synthesize the call (or convert the item into a message), not just populate the field.

Why this is not simply "stop emitting the item"

The standalone named output is an intentional representation for external tool events. Removing it would change the injection semantics that #39782 / #39791 deliberately introduced. The missing piece is a provider-facing representation step: before serialization, either pair the injected output with a synthesized function_call, or represent it as a message item, for providers that verify pairing.

Related, and probably a different mechanism

This is separate from the tool-batch commit race in #44604 (a function_call whose output has not been appended yet). I reproduced that one too, and interestingly the client-side guard that should cover it already exists: normalize_history calls ensure_call_outputs_present, which inserts an "aborted" output for any call whose call_id has no output (codex-rs/core/src/context_manager/normalize.rs). Still, the race reports keep arriving, which suggests the guard is not applied to every path that reaches the wire — for example the first iteration of a turn uses the caller-provided input directly instead of clone_history().for_prompt(...) (codex-rs/core/src/session/turn.rs:1571). That path is worth auditing alongside the batch barrier.

Environment

  • Codex Desktop / app-server 0.154.0-alpha.6.2, macOS arm64
  • Provider: DeepSeek official Responses API (https://api.deepseek.com, wire_api = "responses"), routed through a local gateway on 127.0.0.1
  • Reproduced from a live automation/heartbeat thread and reproduced again with direct wire probes as tabulated above

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    app-serverIssues involving app server protocol or interfaces
    custom-modelIssues related to custom model providers (including local models)
    on Sep 14, 2026
  2. JunkaiWang-TheoPhy commented on Sep 17, 2026

    @JunkaiWang-TheoPhy
    Author

    Wire-level data point that narrows the fix, measured against DeepSeek's official Responses endpoint (https://api.deepseek.com/v1/responses, macOS, app-server 0.154.0-alpha.6.2). Sending the same injected item in three shapes:

    body sent as input[i] result
    {"type":"function_call_output","name":"automation_update","namespace":"codex_app","output":"…"} (today's shape: no call_id) 400 Failed to deserialize the JSON body into the target type: input: missing field 'call_id'
    the same item with a synthesized call_id, still without any matching function_call 400 No tool call found for tool output with call_id …
    a synthesized function_call plus the original output, sharing one call_id 200
    the output rewritten as a plain developer message 200

    So populating call_id on the injected item is not sufficient on its own — the provider requires the pair to be present in the same request. Any fix that only fills the field will turn the first 400 into the second one.

    Two independent constraints showed up while testing on this machine, in case they matter for the fix:

    • Error wording is not a reliable classifier. A transcript with callA → outputA → callB → outputB is rejected with The 'reasoning_text' in the thinking mode must be passed back to the API, even though the actual problem is the batch being split by its own outputs. Keeping the calls contiguous and placing the batch's outputs after it (A → B → outA → outB) returns 200 for the same content.
    • A missing output on a function_call produces No tool output found for tool call X, which is what makes a thread unrecoverable, because the rejected turn stays in the loaded history.

    Happy to provide the raw request/response pairs for any of the rows above if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacesautomationsbugSomething isn't workingcustom-modelIssues related to custom model providers (including local models)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions