Codex side chat sent while the main thread is running falls back to a summary instead of a native thread/fork#29963

opened7d agobyJjoshrad-dev

What happened

I use side chats to plan my next message while the main Codex thread is still working. If I send the side chat's first message while the main thread is running, the side chat doesn't get a native fork of the main thread. It starts a brand-new Codex thread whose first prompt is a T3-written summary of the main thread. That loses the real history (exact tool calls and outputs) and gives up the parent's prompt cache, which is an unnecessary cost.

With the main thread idle, the same action does a native fork. Codex's own TUI /side forks a running thread without waiting, and Codex's thread/fork works while a session is running.

Diagnosis

The fallback is deliberate and rests on the assumption that providers can't fork a busy thread. I read the code at tag v0.1.6 and confirmed the same guard on main at 61c3794c.

  • apps/server/src/orchestration-v2/CommandPolicy.ts:370-390: decideForkExecution returns native_fork only if the source run is completed or waiting and !input.sourceBusy. Otherwise it returns portable_context (full_thread_summary strategy).
  • Orchestrator.ts:6574-6594 and ProviderTurnStartService.ts (:1121-1127 at v0.1.6, :1147-1152 on main): sourceBusy is true when any run after the fork point is active. It is evaluated when the side chat's first turn starts, so opening the side chat before the main thread finishes doesn't avoid it. A comment there says "Providers refuse to fork a busy thread."
  • CommandPolicy.test.ts:270 ("forks a busy source through portable context") asserts the current behavior, so a fix has to change it.
  • The portable path builds the prompt with ContextHandoffService.makeSideChatContext (ContextHandoffService.ts:248).

The assumption looks wrong for Codex, going by the Codex source (openai/codex main, commit dbaead01, read, not run):

  • codex-rs/tui/src/app/side.rs creates /side as an ephemeral fork through the app-server thread/fork request, with no check for a running parent turn.
  • thread/fork is handled in thread_processor.rs with ForkSnapshot::Interrupted. core/src/thread_manager.rs documents it as forking the persisted history "as if the source thread had been interrupted now", with a <turn_aborted> marker if the snapshot ends mid-turn. The source is not interrupted.
  • ThreadForkParams.last_turn_id lets the caller fork at the last completed turn. T3's CodexAdapterV2.forkThread (Adapters/CodexAdapterV2.ts:7819) already sends it.

Suggested direction: don't take the sourceBusy fallback for Codex, and fork natively at the last completed turn. The guard may still be right for other providers, so this probably wants a per-provider capability.

Not verified: I did not run thread/fork with lastTurnId against a running Codex turn. That it accepts a running source comes from reading the Codex source, not from a live test.

Steps to reproduce

  1. Desktop app, Codex provider. Start a thread with a long-running turn.
  2. While it's still running, open a side chat and send a message.
  3. The side chat's context transfer resolves to portable_context with a full_thread_summary handoff (see Evidence), not native_fork.
  4. For comparison, repeat after the main turn has finished: the new side chat's transfer resolves to native_fork (see Evidence).

Version

0.1.6 dev build. The dev server reports serverVersion 0.1.6 and was run from source against a dev profile. I don't know the exact commit. The code analysis uses tag v0.1.6 and was re-checked against main. (t3 triage CLI is also 0.1.6.)

Environment

macOS (darwin arm64, 27.0.0), Node v26.12.1, T3 Code dev build (desktop app run from source, dev profile), Codex provider, with the Codex app-server from CLI 0.166.

Evidence

shell
1Read directly from the app's SQLite state (read-only), table orchestration_v2_projection_context_transfers, plus the related run and handoff tables. Thread ids are shortened and titles and message text are omitted. Times are UTC on 2026-11-03.
2
3Parent thread P (Codex) has three runs:
4run 1 completed 04:58:24.7 → 04:58:29.2
5run 2 completed 05:12:27.6 → 05:12:35.0
6run 3 completed 05:12:44.9 → 05:12:57.8
7Both side chats below are Codex → Codex forks of P. Both have a strong native Codex thread ref and turn ref at the fork point.
8
9Side chat A, first message sent while P was running:
10A created 05:12:32.1 (run 2 running); first message 05:12:46.9 (run 3 running)
11sourcePoint.runId = run 1 (last completed run at creation)
12status = consumed (05:12:46.9)
13resolution.strategy = "portable_context"
14handoffs on A = full_thread_summary, manual_context
15
16Side chat C, first message sent while P was idle:
17C created 05:41:54.6; first message 05:42:01.1 (no run active on P)
18sourcePoint.runId = run 3 (latest completed run)
19status = consumed (05:42:01.7)
20resolution.strategy = "native_fork"
21resolution.providerThreadRef = { driver: "codex", strength: "strong" }
22handoffs on C = manual_context only (short side-chat framing note;
23 no full_thread_summary)
24
25The two cases differ in one condition, whether a later run on P is active when the side chat's first turn starts. A gets a summary of the parent. C forks the real Codex history.

#28809 covers the same side-chat fork context, but only the misleading "Context handoff X → X" label and a recovery instruction that points at the child thread. It doesn't cover why a handoff is used or the cache and fidelity loss, so this isn't a duplicate.

Fix applied or workaround

None. Waiting for the main turn to finish before sending the side chat's first message gives a native fork, but that defeats the purpose.

Filed by

claude (Sonnet 5.5) via t3 triage