Repository navigation
[Bug][死循环]: Codex Desktop tool-call dead loop — OpenAI passthrough drops assistant/tool_call from previous_response_id replay #334
Description
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)
- Install/start OpenCodex so Codex points at the local proxy.
- In Codex Desktop, start a simple research task that needs web search (example: “查一下茅台最新资讯”).
- Watch a single user turn (
turn/startonce only). - Observe many nearly identical “I’m checking…” assistant plans + repeated web searches, no final answer, until the user aborts.
- Inspect OpenCodex
usage.jsonl: manygpt-5.6-sol/provider=openairequests (HTTP 200) aligned with each re-plan. - Inspect
~/.opencodex/responses-state.jsonduring 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-responsesadapter stripsprevious_response_idon forward- local replay is the only chain continuity for passthrough
- empty completed
output+ missing accumulation ofoutput_item.done⇒ broken tool loops in Codex Desktop
Expected behavior
After each model step that emits:
response.output_item.donefor reasoning / message / function_call
…the object recorded for previous_response_id expansion must include those items in order, so the next turn sees:
- prior assistant plan
- prior tool call
- 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):
- Maintain
Map<output_index, item>per SSE stream. - On
response.output_item.done, storeevent.itembyoutput_index. - On
response.completed, ifresponse.outputis empty, backfill from the map sorted by index. - Ensure both
consumeForInspectionandconsumeForResponseLogMetadatapaths use the accumulator beforerememberResponseState.
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/3with 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:
- SSE stream with non-empty
output_item.donesequence but emptyresponse.completed.output - Multi-tool continuation where tool_call must remain paired with tool_result across
previous_response_idexpansions - 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
[-][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[/+]peter20201011-cmyk commentedon Jul 23, 2026
中文摘要 / 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 不增output=0/ 孤儿 tool resultrelay.ts累计output_item.done的 hotfix 后,状态链闭合且死循环消失请求
请在上游修复
relay.ts(及并行 consume 路径)的 empty completed output 回填,并加回归测试。详见正文 English 部分。此评论补充中文可见性;完整技术细节见 issue 正文。
Merge pull request #350 from lidge-jun/codex/260724-passthrough-state
lidge-jun commentedon Jul 23, 2026
Fixed on
devby PR #350 (merged 05ee9ec).createSseInspectornow accumulatesresponse.output_item.doneitems per stream and backfills the completion callback when the terminalresponse.completedpayload arrives with a missing/emptyoutput. 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_idcontinuation. The fix will ship in the next release; until then it is available ondev. Thank you for the exceptionally precise state-level diagnosis — it made the fix straightforward to target.Merge pull request lidge-jun#350 from lidge-jun/codex/260724-passthro…