Skip to content

feat: mid-turn steering and queued follow-up messages in the TUI - #1087

Merged
Astro-Han merged 2 commits into
mainfrom
feat/runtime-steering-queue
Jul 16, 2026
Merged

feat: mid-turn steering and queued follow-up messages in the TUI#1087
Astro-Han merged 2 commits into
mainfrom
feat/runtime-steering-queue

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

The TUI swallowed Enter while a turn was running: the only way to talk to a busy agent was to interrupt it. This adds the two missing delivery semantics, following the pattern pi and codex converged on:

  • Enter during a turn = steer. The message is queued in the runtime and injected at the next step boundary as an envelope-wrapped user message, continuing the same turn. With no live steering owner the CLI keeps the text in a visible pending list, retries the enqueue, and flushes any remainder into the next turn at the turn boundary.
  • Alt+Enter during a turn = queue. Follow-ups drain when the turn finishes (joined into one prompt) and open the next turn, ahead of goal auto-continuation. When idle, Alt+Enter submits like Enter.
  • Alt+↑ retracts everything still queued back into the editor — always through the authoritative runtime queue, never gated on the render mirror; a message already leased to the running step is past the take-back point and lands in the transcript and ledger instead. Double-Esc interrupt disables submission while the stop settles, retracts synchronously, and an aborted turn never auto-opens a new one from leftover queued text — it comes back as a draft.
  • A pending strip above the editor mirrors both queues (Steering: / Queued: rows), driven solely by queue_update events. The kernel is the only producer of that event: a backend-forged queue_update is dropped at the flow ingress and never reaches the ledger or observers.

Delivery is built so a steering message has exactly one owner at all times — the queue, the ledger, or the editor — and is never silently lost:

  • Pull is the commit point: the kernel owns the queues; a step-boundary pull moves messages to in-flight and commits them to delivery. Retract and interrupt reclaim only still-queued text. A pulled lease settles solely by durable outcome: once its steering_message append is durably consumed it is acked and joins the injection set (if the turn aborted meanwhile, the ledger owns it and the next turn's history replay carries it); a lease whose event never persisted is nacked back, folding to the followup head when its issuing turn no longer owns the queue. An ambiguous append error is settled by reading the ledger back: event present ⇒ durable, ack path; definitively absent ⇒ nack. Abort never converts a durable event into a redelivery, and no path can both refill the editor and still deliver.
  • Fail-closed persistence: the steering_message ledger append must succeed before any provider request carries the message; every other non-terminal event keeps its fail-open trace semantics. A session configured without a runtime-event store cannot honor that contract, so steer() falls back to opening a normal turn there.
  • Structural identity: injected, ledger-rebuilt, and text-only-projected steering messages all carry a providerOptions-scoped event-id marker; dedup and the retry-safe base strip operate on ids, never on message text, so user text can neither forge nor cancel a steering. Degraded replay paths recover the marker from a runtime-event sidecar.
  • Measured capacity: steering joins the projected messages before shaping and the final context-window verdict, and mid-turn checkpoint folds pin the active turn's steering events out of the foldable span, so the capacity owner always measures the request the provider actually receives.

Commits are split by layer: runtime/core and cli. The runtime commit body documents the full invariant set.

Verification

  • Runtime suite: 2018 tests, 2011 pass, 0 fail (7 pre-existing skips). CLI suite: 449 pass, 0 fail. Three-package typecheck and Biome lint clean, re-run after the final rebase onto main.
  • Externally reviewed (Codex) in six rounds — two scoped rounds, two fresh-eye full reviews, and two first-principles design reviews. Every adjudicated finding is fixed with a regression test written to fail first, including: double-injection on transport retry, requests racing ahead of persistence, a retract racing a pending ledger append, an abort turning a durable steering event into a redelivery, checkpoint folds sneaking steering past the capacity verdict, late nacks stranding text in an ownerless queue, batch lease settlement redelivering consumed messages, ambiguous store errors double-delivering via nack, mirror-gated retraction, and backend-forged queue snapshots.
  • Not verified live against a real provider (no credentials in this environment); the injection path is covered by mock-provider and fake-backend tests.

Known windows (documented, accepted)

  • A permanently hung ledger append leaves the turn waiting for that write, matching the pre-existing behavior of any hung append; abort does not force-settle a pending steering write, because settlement is by durable outcome only.
  • A store that fails the append and the read-back leaves the message on the queue side (nack); if that write actually landed, history carries a semantic duplicate — never a double execution within a turn. The full storage contract (idempotent appends with committed/absent/unknown outcomes) is a system-wide follow-up, tracked separately.

Review focus

  • runtime-kernel.ts: the lease/ack/nack state machine, the pull commit point, and its turn-identity guards.
  • model-history.ts / ai-sdk-backend.ts / mid-turn-capacity-compact.ts: the id-marked steering projection, the sidecar recovery on degraded replay, and the fold-pin that keeps steering inside the measured payload.

@Astro-Han
Astro-Han force-pushed the feat/runtime-steering-queue branch 4 times, most recently from 11bae0a to 5245712 Compare July 16, 2026 12:17
…y injection

Add two authoritative per-session queues in the runtime kernel so a user can
act on a turn while it runs:

- steer(sessionId, text): queue a message injected into the running turn at the
  next step boundary. The backend leases it via BackendSendInput.pullSteering,
  echoes it as a first-class `steering_message` (persisted as a user event,
  rendered in place), and injects it into the model context wrapped in a
  steering envelope so it continues the same turn. With no live steering owner,
  steer returns a `fallback` outcome so the caller opens a fresh turn and the
  message is never lost.
- queueMessage(sessionId, text): queue a followup that opens the next turn;
  drainFollowup joins them with blank lines at turn end.
- retractQueue / interrupt clear both queues; queue changes emit a `queue_update`
  snapshot into the active turn's stream, including the final stranded-steering →
  followup migration at ownership release (emitted before the stream closes).

Delivery is a lease state machine whose single atomic commit point is the
pull:

- pullSteering moves messages to an in-flight set; retract/clear reclaim
  QUEUED messages only. A pulled lease is committed to the running turn and
  settles exactly once, decided solely by the persistence fact: the backend
  acks after the echoed event is durably consumed AND in the injection set,
  and nacks only when the event provably never persisted (never pushed, or
  the consumer detached first) — the dying request never carries it. An
  ambiguous append error is settled by reading the ledger back: present ⇒
  durable (ack path); a store that fails both the append and the read-back
  leaves the message on the queue side (nack). An
  abort does not settle a pushed lease: the wait rides the teardown's
  convergence, so a durably appended event is never nacked into a second
  delivery — the ledger owns it and the next turn replays it. Snapshots keep
  counting in-flight messages as pending. Each lease is bound to its issuing
  turn: an overlapping turn that takes the owner slot can neither invalidate
  the issuer's ack/nack nor reclaim another turn's in-flight lease (release
  folds only its own turn's leases), and a nack that lands after the issuer
  lost ownership folds into the followup queue front — the issuer will never
  pull again, so the steering queue would strand it ownerless.
- Steering persistence is fail-CLOSED end to end: AgentRun rethrows a steering
  append failure (every other non-terminal event stays fail-open), which
  propagates through the event chain to the backend's durability wait and
  becomes a nack.
- Identity is structural, never textual: the persisted steering event carries a
  `steering` marker; every model projection (live injection, transport retry,
  reactive overflow rebuild, mid-turn capacity replacement, future-turn
  history, the text-only projections, and the degraded StoredMessage
  fallbacks via a RuntimeEvent sidecar)
  emits the one canonical envelope form tagged with the ledger event id in a
  Maka-namespaced providerOptions entry. Dedupe and the steering-free transport
  retry base work by that id — the retry base strips only THIS turn's injected
  set (historical ledger-replayed steering stays in the base), and user text
  equal to the envelope can neither forge nor cancel a steering message.
- The capacity owner measures the real payload: steering joins the request
  BEFORE shaping and the final-request verdict, and the mid-turn fold PINS the
  current turn's steering events out of the covered span (folding one never
  shrinks the outgoing request — the accumulator re-appends it), so the
  verdict always measures the steering-inclusive payload that actually goes
  out and no post-verdict re-append path exists.
- Without a configured RuntimeEventStore the durability ack has no anchor:
  steer() reports `fallback` and the message opens a fresh turn whose user
  message the SessionStore persists with the ordinary turn-open guarantee.
- Enqueue outcomes carry no queue snapshot: queue contents travel to
  observers on ONE path only, the `queue_update` event — and the kernel is
  its only legal producer. A backend-yielded queue_update is forged queue
  state: the flow drops it at the ingress (not mapped, not forwarded, not
  persisted), and the event mapper rejects it (BackendSessionEvent excludes
  it from the backend vocabulary).
- Ownership is a single live top-level run: established only after run.begin()
  succeeds, bound to the run's turnId (the lease/ack/nack hooks re-check
  identity), released before the stream closes.

Threads the lease hooks through InvocationRequest -> FlowInput -> AiSdkFlow ->
backend. FakeBackend follows the same lease contract with per-LEASE
settlement: each lease acks right after its own echoed event is consumed (its
delivery boundary), and a detaching consumer nacks exactly the leases whose
events never crossed their yield. New core contracts:
SteeringMessageEvent, QueueUpdateEvent, SteeringQueueSnapshot,
QueueEnqueueOutcome, SteeringLease, and the `steering` marker on
RuntimeEventTextContent.
Wire the TUI to the runtime's steering/followup queues:

- Enter during a turn steers the running turn instead of being swallowed; the
  editor stays submittable and routes non-empty input to driver.steer.
- Alt+Enter queues a followup during a turn (submits like Enter when idle);
  during a control action (busy without a running turn) it returns before
  touching the editor so the draft is never cleared into the void.
- A pending bar above the editor mirrors the queues (`Steering:` / `Queued:`),
  driven solely by queue_update events; the CLI holds only a mirror.
- A `fallback` outcome while the turn is running (the runtime's begin window has
  no live steering owner yet) moves the text into a CLI-held pending list that
  retries the SAME enqueue every 100ms and is rendered in the pending bar; the
  turn boundary flushes any remainder into the next turn's prompt (ahead of
  queued followups), so delivery is guaranteed — never a bounded wait that a
  normal turn outlives.
- Alt+↑ takes every queued message (both queues plus CLI-held fallback texts)
  back into the editor, prepended to the draft — always through the
  authoritative retract, never gated on the render mirror, which lags the
  queue_update event by design.
- Double-Escape interrupt retracts synchronously from driver.retractQueued() —
  the authoritative QUEUED messages (a message the turn already leased is
  committed to delivery and lands in the transcript instead) — plus the
  CLI-held fallback texts, into the editor; never from the render mirror,
  which can lag a step-boundary consumption.
- The interrupt convergence window (stop issued, turn not yet terminal) accepts
  no new input: submits are disabled and Alt+Enter is gated before touching the
  editor, so nothing can race the abort.
- Queued followups open the next turn at turn end, before goal auto-
  continuation — but only when the turn COMPLETED. An aborted or errored turn
  never auto-opens the flush turn; undelivered text becomes an editable draft.

The driver exposes steer/queueMessage/takePendingFollowup/retractQueued over the
runtime's queue API. New keys were chosen to avoid pi-tui editor bindings
(newline is shift+enter/ctrl+j, history is plain up), so they do not collide.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant