Skip to content

feat(codex,claude): mid-turn steering — deliver queued messages at step boundaries, not only on full-turn completion #888

Description

@swear01

Summary

Today, when the operator sends a message while an agent turn is still running, HAPI queues it and only delivers it after the entire turn terminates (full agentic loop, possibly a dozen tool calls). Both native CLIs we wrap instead deliver such "steering" input at the next step boundary inside the running turn (after the current model output / next tool call), without a hard interrupt.

This issue proposes adding mid-turn steering (opt-in), and documents the per-flavor feasibility I verified. The two flavors are asymmetric, so they need different implementations:

  • Codex — the app-server protocol has a first-class, non-interrupting turn/steer RPC. HAPI just doesn't call it. Clean to implement.
  • Claude — there is no non-interrupting steer primitive in --input-format stream-json. I verified empirically that mid-turn stdin messages are ignored by the running turn. Steering would require interrupt + resubmit (a soft interrupt), which is a different UX.

Current HAPI behavior (both flavors deliver only at full-turn end)

Codex (cli/src/codex/codexRemoteLauncher.ts):

  • turnInFlight is cleared only on a terminal event, and wakeLoop() is called only there — codexRemoteLauncher.ts:2159-2172.
  • The main loop parks in waitForTurnOrRecovery (:1797) until the turn fully ends, then pulls the next queued message via session.queue.waitForMessagesAndGetAsString (:2955).
  • The app-server client exposes startThread / resumeThread / startTurn / interruptTurn / compactThread / goalno steer/inject (cli/src/codex/codexAppServerClient.ts).

Claude (cli/src/claude/claudeRemote.ts):

  • scheduleNextMessage() (which pushes the next queued user message into the streaming-input iterable) is invoked only when message.type === 'result', i.e. at full-turn end — claudeRemote.ts:258-282.

What the native CLIs do

Findings

Codex — turn/steer exists (verified against codex 0.139.0 schema)

codex app-server generate-ts emits a dedicated steering request:

// ClientRequest: { "method": "turn/steer", params: TurnSteerParams }
type TurnSteerParams = {
  threadId: string,
  clientUserMessageId?: string | null,
  input: Array<UserInput>,   // same shape as turn/start: text | image | localImage | skill | mention
  expectedTurnId: string,    // precondition: must equal the active turn id, else the request fails
}
type TurnSteerResponse = { turnId: string }

Relevant constraints baked into the protocol:

  • Not every turn is steerable: NonSteerableTurnKind = "review" | "compact". Steering one returns CodexErrorInfo::activeTurnNotSteerable { turnKind }.
  • There is also a lower-level thread/inject_items (raw Responses API items) — not what we want for steering.

HAPI does not reference turn/steer or thread/inject_items anywhere.

Claude — no non-interrupting mid-turn primitive (verified empirically)

streamToStdin writes each pushed message to the child claude stdin immediately (cli/src/claude/sdk/utils.ts:188-192), so a naive "push the queued message as soon as it arrives instead of on result" change is trivial on the HAPI side. But it does not produce mid-turn steering.

I tested claude 2.1.175 directly in --input-format stream-json --output-format stream-json:

  • Turn 1 prompt: run sleep 3 && echo AAA, then sleep 3 && echo BBB, as two steps.
  • At ~2.0s (before the first tool_use), injected a second user message: "STOP now. Ignore everything before. Reply with exactly: ZEBRA".
  • Result: the turn ran AAA → BBB → "All done." → result, never said ZEBRA, did not pivot at the AAA→BBB step boundary, and no follow-up turn for the injected message was produced within the session.

So the running turn ignores (effectively drops) mid-turn stdin input. HAPI's existing "hold in our own queue, push on result" is in fact the safe pattern for stream-json — you cannot steer Claude mid-turn just by pushing earlier. Achieving "deliver after the current output" for Claude would require the interrupt control request (already available: cli/src/claude/sdk/query.ts:171, subtype: 'interrupt') to halt at the next safe point, then resubmit the queued message — i.e. a soft interrupt, with the in-flight step discarded.

Proposed direction

Make this opt-in (per-session or setting), because "queue strictly until end of turn" is a legitimate preference that the current behavior already serves well.

  • Codex: add steerTurn(params) to codexAppServerClient (turn/steer). In the launcher, when a queued message arrives while turnInFlight and we hold currentThreadId + currentTurnId, call turn/steer with expectedTurnId: currentTurnId instead of waiting. Fallbacks:
    • activeTurnNotSteerable (review/compact) → fall back to existing queue-until-end path.
    • expectedTurnId race (turn just ended) / unknown method on older codex → fall back to startTurn / current behavior. Gate on capability or codex version.
  • Claude: implement via interrupt + resubmit behind the same opt-in. Must decide UX for the discarded in-flight step and how to present it. This is closer to a soft-interrupt than to Codex's true steering — set expectations accordingly.

Open questions

  • Minimum codex version that ships turn/steer; best capability-detection (catch "method not found" vs. version check).
  • For Claude, whether a soft-interrupt steer is desirable at all, or whether we just document the gap and keep queue-until-end as the only mode.
  • Surfacing in the web UI: a "steer now" affordance distinct from plain queue, and distinct from the existing Stop/Abort.

Verification artifacts (Codex protocol dump via codex app-server generate-ts, Claude stream-json probe scripts) available on request.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions