Problem
When a user sends a steer message near the end of a Codex turn, Controller can reject it with No active turn for this session even though the UI still presents the conversation as streaming and allows the steer shortcut.
There is a race between the Codex app-server lifecycle and the client SSE lifecycle:
CodexAppServerManager.emit() clears turnInProgress and currentTurnId as soon as run.completed arrives.
SessionView keeps the composer in streaming/steer mode until the later top-level done SSE event.
- The route waits for queued event persistence, diff calculation, session updates, and stream finalization before sending
done.
- A steer submitted in that interval reaches
steerSession() after the active Codex turn has already ended and is rejected.
The failed request also causes message loss/confusing UI state: handleSteer() clears the draft and inserts an optimistic local user message before the server accepts the steer. On failure, the message remains visible locally with an error but was never persisted or delivered, and disappears after reload.
The current turn/steer payload matches the installed Codex app-server schema; this is Controller lifecycle coordination rather than protocol drift.
Expected behavior
A message submitted while the UI is transitioning from an active Codex turn to its completed state must not be lost:
- If the turn is still steerable, deliver it through native
turn/steer.
- If the turn completed before the steer could be accepted, preserve it as a queued/follow-up message for the next turn.
- Do not clear the composer or show a delivered user message until the server has accepted responsibility for it.
- The UI should stop offering native steering as soon as it observes terminal
run.completed / run.failed state, rather than waiting only for SSE done.
Acceptance criteria
Relevant code
server/lib/codex-app-server.ts: steerSession() and terminal-event state cleanup.
server/routes/sessions.ts: Codex SSE finalization and /steer route.
client/src/pages/SessionView.tsx: terminal event handling and handleSteer() optimistic state changes.
Problem
When a user sends a steer message near the end of a Codex turn, Controller can reject it with
No active turn for this sessioneven though the UI still presents the conversation as streaming and allows the steer shortcut.There is a race between the Codex app-server lifecycle and the client SSE lifecycle:
CodexAppServerManager.emit()clearsturnInProgressandcurrentTurnIdas soon asrun.completedarrives.SessionViewkeeps the composer in streaming/steer mode until the later top-leveldoneSSE event.done.steerSession()after the active Codex turn has already ended and is rejected.The failed request also causes message loss/confusing UI state:
handleSteer()clears the draft and inserts an optimistic local user message before the server accepts the steer. On failure, the message remains visible locally with an error but was never persisted or delivered, and disappears after reload.The current
turn/steerpayload matches the installed Codex app-server schema; this is Controller lifecycle coordination rather than protocol drift.Expected behavior
A message submitted while the UI is transitioning from an active Codex turn to its completed state must not be lost:
turn/steer.run.completed/run.failedstate, rather than waiting only for SSEdone.Acceptance criteria
run.completedand SSEdoneis preserved and runs as a follow-up instead of producingNo active turn for this session.turn/steerand persists the accepted user message exactly once.Relevant code
server/lib/codex-app-server.ts:steerSession()and terminal-event state cleanup.server/routes/sessions.ts: Codex SSE finalization and/steerroute.client/src/pages/SessionView.tsx: terminal event handling andhandleSteer()optimistic state changes.