IDE: resumed compacted thread loses exec/code-mode tools on Responses WebSocket

Open 💬 1 comment Opened Jul 28, 2026 by xionghaizhi

What version of the IDE extension are you using?

openai.chatgpt 26.721.41059, bundled codex-cli 0.146.0-alpha.3.1.

What subscription do you have?

Not captured during diagnostics.

Which IDE are you using?

VS Code Remote.

What platform is your computer?

Linux 5.10.0-60.18.0.50.oe2203.x86_64 x86_64 x86_64

What issue are you seeing?

An older IDE thread containing a compacted record with replacement_history resumes successfully, but the first turn after reopening has no usable terminal/file-edit tool surface. The assistant reports that no terminal execution tool is available.

The same workspace and permission profile work in a fresh thread. Forking the affected thread reproduces the failure. A clean, unmodified OpenAI extension also reproduces it, so no Codex Local Groups patch is required.

The VS Code Remote reconnect and Codex runtime resume both succeed. Sanitized Extension Host logs show:

maybe_resume_success
requestPermissionProfile=:danger-full-access
requestRuntimeWorkspaceRootCount=1

This rules out a missing workspace root, denied permission profile, or failed Extension Host resume as the immediate cause.

What steps can reproduce the bug?

  1. Use a long VS Code Codex thread until its rollout contains compacted.payload.replacement_history.
  2. Close/reopen VS Code, or navigate away and reopen the old thread.
  3. Ask the resumed thread to run printf "TOOL_OK\n".
  4. Observe that it reports no terminal execution tool is available and emits no command execution.
  5. Create a fresh thread in the same workspace and repeat. The command executes.
  6. Fork the affected thread and repeat. The fork remains affected.

Sanitized transport A/B

The same stored compacted history was replayed with request-shape instrumentation:

Responses WebSocket

startup prewarm response.create: generate=false, top-level tools_count=0
actual response.create: top-level tools_count=0
result: no command execution

Responses HTTP POST

same stored history
result: command execution emitted
output: TOOL_OK

Fresh thread over Responses WebSocket

result: command execution emitted
output: TOOL_OK

The WebSocket connection itself is healthy. The failure is specific to resumed compacted history on the WebSocket path.

For Responses Lite models, code-mode tools may be represented through additional_tools, so the decisive symptom is the missing usable tool surface. This report does not assume top-level tools must always be non-empty. The likely boundary is that resume/prewarm does not preserve or rebuild the current step's effective tool manifest.

What is the expected behavior?

Resuming compacted history should rebuild the current runtime tool surface independently of stored conversation items. Responses WebSocket prewarm and the actual turn should expose the same usable code-mode tools as HTTP and a fresh thread.

Additional information

Suggested regression test:

  1. Create a thread with compacted replacement history.
  2. Resume it through app-server.
  3. Use Responses WebSocket startup prewarm.
  4. Run a turn that calls public exec and then exec_command.
  5. Assert command execution succeeds.
  6. Compare the effective tool/additional_tools state with the HTTP request for the same stored history.

Related but distinct reports:

  • #34719: post-compaction deferred app/MCP tools disappear, but shell/core tools remain.
  • #31894: fresh codex exec with Responses Lite does not expose code-mode tools; this report is resume/compaction and WebSocket-specific, with fresh WebSocket and same-history HTTP controls succeeding.
  • #35298: Remote reconnect removes app connector tools; this report loses native exec/file-edit tools and is tied to compacted-history WebSocket replay.
  • #25990: older threads miss newly introduced dynamic tools; this report loses tools that remain available in a fresh thread on the same runtime.

Raw rollout files, provider configuration, and transcripts are private. Sanitized request-shape traces can be provided.

View original on GitHub ↗

1 Comment

antmanler · 10 days ago

Independently reproduced this and traced it to a specific mechanism in the Responses-over-WebSocket incremental request path. Your transport A/B (WS fails on resumed compacted history, HTTP succeeds on the same history, fresh thread over WS succeeds) matches our matrix exactly, and the mechanism below explains every cell — including two more we observed: a warm post-compaction turn succeeds, while thread/fork of a compacted thread fails even in the same process.

TL;DR: the tool table is transported as input[0] (spliced into the front of the request input), and the first turn after a fresh-process resume is sent as a prefix-based delta chained on the client-synthesized prewarm response. The tool table is therefore always the first casualty of the delta. When that delta contains a compaction item — a context-replacement directive — the upstream rebuilds context from the delta alone, with zero tool declarations (no additional_tools input item, no top-level tools field). No error frame is emitted; the failure is entirely silent.

Affected versions

  • rust-v0.147.0 (confirmed, deterministic 4/4 via app-server JSON-RPC)
  • rust-v0.148.0-alpha.9 (confirmed, same behaviour)

Reproduced against a Responses API-compatible proxy. Requires an upstream that rebuilds context from a compaction input item rather than extending the chained prewarm state; it is not observable against an upstream that persists prewarm response state and treats the delta as a pure extension.

Repro (app-server JSON-RPC, no IDE needed)

  1. thread/start with dynamicTools (flat form and type: "namespace" form both reproduce).
  2. Send one turn that calls one of the dynamic tools — succeeds.
  3. thread/compact/start.
  4. Kill the codex app-server process.
  5. thread/resume in a fresh process (threadId only — restoration from session_meta is expected and does work; the loss is not in rollout reconstruction).
  6. Send a turn asking the model to call the dynamic tool.

Expected: the tool is callable, as it is on a warm post-compaction turn.
Actual: the model reports having no tools (including exec) and emits no tool calls. The outbound WebSocket frame carries no tool declarations.

A resume/fork of a thread with no compaction in its rollout does not reproduce.

Mechanism (line references at rust-v0.147.0)

  1. The tool table is spliced into the front of the request input rather than carried as a distinct field — codex-rs/core/src/client.rs:884:

``rust
input.splice(0..0, prefix); // prefix = [AdditionalTools, developer instructions]
``

  1. The incremental path matches a prefix of the previous request's input and sends only the remainder — codex-rs/core/src/client.rs:1188-1226:

``rust
let previous_items_len = previous_request.input.len().checked_add(response_items.len())?;
let (request_items_to_compare, incremental_items) = request.input.split_at_checked(previous_items_len)?;
// ... prefix items compared pairwise ...
Some(incremental_items.to_vec())
``
The comparison only inspects the prefix; it has no notion that the remainder is a post-compaction context rebuild.

  1. The delta replaces the full input on the wire — codex-rs/core/src/client.rs:1651-1660:

``rust
let ws_payload = ResponseCreateWsRequest {
previous_response_id,
input: incremental_items.as_deref().unwrap_or(&request.input),
generate: if warmup { Some(false) } else { None },
...
``

  1. The baseline for that prefix is the prewarm request (:1771 sends warmup = true, stored as last_request at :1685; the first real turn follows at :1830).

The prewarm input is exactly [additional_tools, developer_instructions], so previous_items_len == 2 and the first real turn's delta is input[2..] — the entire conversation minus the tool table.

Observed frames (RUST_LOG=trace, tungstenite)

Prewarm (generate: false), identical in working and failing runs:

input[0] additional_tools(namespaces = [functions, collaboration, example_ns])
input[1] message/developer("You are Codex, ...")

Warm post-compaction turn — prefix comparison fails (last request was the compaction trigger), so a full request is sent and the tool call succeeds:

previous_response_id: null
input[0] additional_tools(...)
input[1] message/developer
input[2] message/user
input[3] compaction
input[4..] ...

First turn after a fresh-process resume — delta path engages and the tool call fails:

previous_response_id: "resp_prewarm_<uuid>"   <- client-synthesized, not a server id
input[0] message/user       <- equals full input[2]
input[1] compaction         <- equals full input[3]
input[2..] ...              <- equals full input[4..]
no additional_tools item anywhere; no top-level "tools" field

The failing frame is byte-for-byte the full input with [additional_tools, developer_instructions] removed. Top-level keys on that frame: client_metadata, include, input, model, parallel_tool_calls, previous_response_id, prompt_cache_key, reasoning, service_tier, store, stream, text, tool_choice, type — zero tool declarations anywhere.

Why each matrix cell behaves the way it does:

| Case | Delta contains compaction? | Result |
|---|---|---|
| Fresh thread over WS | no (pure extension; chained state covers tools) | works |
| Resumed compacted thread, first turn | yes (context rebuild sent as increment, tools in stripped prefix) | fails |
| Warm post-compaction turn | n/a — prefix mismatch forces full resend | works |
| Fork of compacted thread (even in-process) | yes (new session → new prewarm baseline) | fails |
| Same history over HTTP | n/a | works |

Design claim

The tool table should not live inside a truncatable history prefix. Either:

  • carry tool declarations in a field the delta path never elides, or
  • have the delta path unconditionally retain ResponseItem::AdditionalTools, or
  • disable the delta path for any request whose input contains a compaction item, since such a request is a context rebuild rather than an extension.

Workaround

For custom providers, disable the WebSocket transport:

[model_providers.<id>]
supports_websockets = false

responses_websocket_enabled() (codex-rs/core/src/client.rs:949-957) then returns false and every request goes over HTTP, where ResponsesApiRequest has no previous_response_id field at all (:918-934) and the tool table is rebuilt per request (:862-891). Confirmed to make the full repro matrix pass — and notably, already-broken compacted threads heal immediately on the next turn, no thread recreation needed: rollout restoration is intact (the tools are in the session config), only the WS turn frame omits them.