Skip to content

[Bug][死循环]: Codex Desktop tool-call dead loop — OpenAI passthrough drops assistant/tool_call from previous_response_id replay #334

Closed

Description

@peter20201011-cmyk

Summary

On OpenCodex 2.7.34 (and still present in 2.7.35 stock relay.ts before a local hotfix), the ChatGPT/OpenAI passthrough path can lose assistant / reasoning / custom_tool_call items when replaying multi-turn tool use via previous_response_id.

Codex Desktop then only re-injects tool results into the next model call. The model does not see that it already decided to call the tool, so it re-plans and re-calls the same tool (e.g. the same web search 10–13 times in one turn). This presents as a hard agent tool dead loop and burns large amounts of tokens.

Environment

  • OpenCodex: 2.7.34 → upgraded to 2.7.35 (stock still needs the same fix)
  • Codex Desktop / app-server: 0.145.0-alpha.30 (also seen with nearby 0.14x)
  • OS: macOS (Apple Silicon)
  • Route: Design B loopback inject
    openai_base_url = "http://127.0.0.1:10101/v1"
    model: gpt-5.6-sol (passthrough, not a third-party adapter)
  • Effort: xhigh (also reproducible as a failure mode when the state hole exists; not required as the sole cause)

Repro (observed in production)

  1. Install/start OpenCodex so Codex points at the local proxy.
  2. In Codex Desktop, start a simple research task that needs web search (example: “查一下茅台最新资讯”).
  3. Watch a single user turn (turn/start once only).
  4. Observe many nearly identical “I’m checking…” assistant plans + repeated web searches, no final answer, until the user aborts.
  5. Inspect OpenCodex usage.jsonl: many gpt-5.6-sol / provider=openai requests (HTTP 200) aligned with each re-plan.
  6. Inspect ~/.opencodex/responses-state.json during the loop:
Snapshot Assistant Reasoning Tool calls Tool results Orphan results
End of first loop wave 0 0 0 13 13
After full history restored 13 13 13 13 0
After three more continuations 13 13 13 16 3

Interpretation: each passthrough continuation stores tool results but drops the model’s just-produced decision items (reasoning / message / custom_tool_call). Next expansion of previous_response_id therefore cannot show the model that the search already happened.

Offline sandbox against installed 2.7.34 source (no live model keys required for the state-capture path) reproduced:

  • SSE actually emits 3 output items (reasoning + assistant + tool call)
  • OpenCodex final capture: output = 0
  • After bad replay: only tool returns remain; orphan tool results appear

Root cause (code-level)

Passthrough path strips previous_response_id before forwarding to ChatGPT Codex REST (upstream rejects it), and relies on local responses-state.json expansion as the only continuation mechanism (documented in src/server/responses.ts around the passthrough / rememberResponseState comments).

In src/server/relay.ts, SSE consumption can complete with an empty response.completed.response.output while individual items were already delivered as response.output_item.done. Those done items are not always accumulated into the final response object used by rememberResponseState, so the continuation cache loses assistant/reasoning/tool_call history.

Relevant stock behavior (2.7.34/2.7.35 before hotfix):

  • openai-responses adapter strips previous_response_id on forward
  • local replay is the only chain continuity for passthrough
  • empty completed output + missing accumulation of output_item.done ⇒ broken tool loops in Codex Desktop

Expected behavior

After each model step that emits:

  • response.output_item.done for reasoning / message / function_call

…the object recorded for previous_response_id expansion must include those items in order, so the next turn sees:

  1. prior assistant plan
  2. prior tool call
  3. tool result

and can stop instead of re-calling the same tool.

Fix that worked locally

Minimal hotfix in src/server/relay.ts (and any parallel consume path used for inspection/logging):

  1. Maintain Map<output_index, item> per SSE stream.
  2. On response.output_item.done, store event.item by output_index.
  3. On response.completed, if response.output is empty, backfill from the map sorted by index.
  4. Ensure both consumeForInspection and consumeForResponseLogMetadata paths use the accumulator before rememberResponseState.

Sketch:

const completedItems = new Map<number, unknown>();

if (event.type === "response.output_item.done") {
  completedItems.set(event.output_index, event.item);
}

if (
  event.type === "response.completed" &&
  Array.isArray(event.response?.output) &&
  event.response.output.length === 0
) {
  event.response.output = [...completedItems.entries()]
    .sort(([a], [b]) => a - b)
    .map(([, item]) => item);
}

Local verification after hotfix:

  • Offline regression: related suite 101/101 + new cases for empty-completed backfill
  • Live multi-tool chain metrics closed as 1/0 → 2/1 → 3/2 → 3/3 with 0 orphan tool results
  • No more “return to first plan / re-run first tool” loops on the same research tasks

Note: upgrading package version alone to 2.7.35 (stock) did not remove the defect; a source-level fix in relay.ts (or equivalent) is required.

Impact

  • High: Codex Desktop + OpenCodex OpenAI passthrough + multi-step tools (web search, shell, etc.)
  • Symptom: “dead loop” of repeated plans/tools inside one user turn; large token burn; user must abort
  • Provider field still shows openai (passthrough), so the bug is easy to mis-attribute to the model alone

Request

Please fix empty response.completed.output handling so output_item.done items are always merged into the continuation state for OpenAI passthrough, with regression tests covering:

  1. SSE stream with non-empty output_item.done sequence but empty response.completed.output
  2. Multi-tool continuation where tool_call must remain paired with tool_result across previous_response_id expansions
  3. No orphan tool results after N continuations

Happy to provide redacted responses-state snapshots / usage timestamps if useful.

Related local notes

  • Design B inject (openai_base_url → loopback) is required for this path to be in use.
  • This is separate from third-party model ladders (Kimi/Grok reasoning effort UI mapping).

Activity

  1. changed the title [-][Bug]: OpenAI passthrough drops assistant/reasoning/tool_call from previous_response_id replay (causes tool-call dead loops in Codex Desktop)[/-] [+][Bug][死循环]: Codex Desktop tool-call dead loop — OpenAI passthrough drops assistant/tool_call from previous_response_id replay[/+] on Jul 23, 2026
  2. peter20201011-cmyk commented on Jul 23, 2026

    @peter20201011-cmyk
    Author

    中文摘要 / Chinese summary

    现象

    Codex Desktop 在接入 OpenCodex(openai_base_url → 127.0.0.1:10101)后,单次用户消息内反复出现几乎相同的「继续查询/核对」计划,并重复调用同一工具(例如网页搜索 10–13 次),无法给出最终回答,直到用户手动中断。单 turn 内 token 急剧膨胀。

    根因

    OpenCodex 对官方 OpenAI 模型 passthrough 时会剥掉 previous_response_id,改用本地 responses-state.json 续上下文。relay.ts 在 SSE 中若只收到 response.output_item.done 而 response.completed.output 为空,会漏存 assistant / reasoning / tool_call,只留下 tool result。下一轮模型看不到自己已调用过工具,于是再次调用 → 死循环。

    验证

    • 真实事故会话 + usage.jsonl 与时间对齐
    • responses-state 计数:工具结果增加而 assistant/tool_call 不增
    • 2.7.34 离线沙盒可稳定复现 output=0 / 孤儿 tool result
    • 本地对 relay.ts 累计 output_item.done 的 hotfix 后,状态链闭合且死循环消失

    请求

    请在上游修复 relay.ts(及并行 consume 路径)的 empty completed output 回填,并加回归测试。详见正文 English 部分。


    此评论补充中文可见性;完整技术细节见 issue 正文。

  3. added a commit that references this issue on Jul 23, 2026
  4. lidge-jun commented on Jul 23, 2026

    @lidge-jun
    Owner

    Fixed on dev by PR #350 (merged 05ee9ec).

    createSseInspector now accumulates response.output_item.done items per stream and backfills the completion callback when the terminal response.completed payload arrives with a missing/empty output. Persisted continuation state now retains assistant/reasoning/tool_call items, which removes the dead-loop condition described here. The client-facing SSE bytes remain byte-identical (regression-asserted).

    Regression coverage includes the exact reported shape: done-items followed by an empty terminal output, verified against saved state on the next previous_response_id continuation. The fix will ship in the next release; until then it is available on dev. Thank you for the exceptionally precise state-level diagnosis — it made the fix straightforward to target.

  5. added a commit that references this issue on Sep 17, 2026
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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions