Files
hapi/web/src/lib/messageDelivery.ts
T
SSU-WEI HUANGandGitHub e6b9fd68e6 feat(pi): queue mid-turn messages by default; steer only via explicit per-message Steer button (#1480)
* feat(pi): queue mid-turn messages by default; steer only via explicit per-message Steer button

Pi (PyAgent) was the only flavor whose ordinary composer submission while
streaming bypassed the queue: the web resolved it to deliveryMode 'steer'
and the CLI dispatched a native steer into the running turn immediately,
with no waiting state. This makes Pi match Codex/Claude behavior (issue
#1466): mid-turn messages wait in the queue by default, and the operator
delivers one into the running turn with the new per-queued-message Steer
button.

- web: resolveMessageDeliveryMode now queues for every flavor; QueuedMessagesBar
  gains a Steer button (pi + thinking + remote-controlled + immediate rows)
  backed by a new useSteerQueuedMessage hook + api.steerMessage.
- hub: POST /sessions/:id/messages/:messageId/steer -> syncEngine.steerQueuedMessage
  (pi-only gate, remote-only, scheduled/absent/invoked rejection) -> RPC.
- cli: pi runner registers 'steer-queued-message'; a queued message is promoted
  into the active turn via the existing PiSteerDispatcher (target generation
  captured at promote time; turn-ended steers fall back to the prompt FIFO).
  Steers requested while the message is still preparing are deferred and
  promoted right after preparation completes.
- Removed the now-dead Alt+Enter / touch-hold queue gesture (its only purpose
  was opting out of the removed automatic steer).

Verified: bun typecheck; cli/hub/web/shared suites (env-dependent runner
integration + kimi wire-locator flakes reproduce on pristine upstream and
are unrelated to this diff).

* fix(pi): preserve queued messages on rejected steers and pin the steering generation

Addresses both Major findings from the HAPI Bot review of PR #1480.

- steerDispatcher: a deterministic native rejection (Pi responded error) now
  degrades the message to the ordinary prompt FIFO instead of emitting
  messages-consumed. A promoted queued message must not be lost just because
  the steer was rejected; the hub row stays queued until the FIFO delivers it.
  The indeterminate-timeout path keeps its fail-closed consume + escalate
  behavior (a duplicate delivery would be worse).
- runPi: the deferred-steer path now captures the streaming generation at RPC
  request time (Map<localId, generation>) instead of reading it after
  preparation completes, so a steer requested against turn G1 can never be
  injected into a turn G2 that started while the message was preparing — the
  dispatcher's generation-mismatch check degrades it to the FIFO.

Regression coverage: negative steer response preserves the entry via the FIFO
(no consume); generation rollover while preparing delivers as a normal prompt
at the next settle (no steer into the new turn).

Verified: bun typecheck; cli pi suites (48 tests), hub 1041, web 2301, shared
240 — all green; only the pre-existing environment-dependent runner
integration test fails locally (reproduces on pristine upstream).

* fix(pi): reject all scheduled steers and always clear deferred-steer bookkeeping

Addresses the two Minor findings from the HAPI Bot follow-up review.

- hub: steerQueuedMessage rejects every scheduled row — mature ones included —
  aligning the endpoint with the web UI (Steer is never offered on scheduled
  rows) and preserving scheduled-FIFO delivery semantics.
- cli: the deferred-steer bookkeeping map is now cleared in a finally on the
  preparation chain, covering the early exits (cancellation before/after
  attachment I/O, empty prepared message, preparation failure) that previously
  could leave a stale generation entry behind for the session lifetime.

Regression coverage: hub steer gate tests (mature scheduled row stays queued,
non-pi flavor rejected) and a runPi test proving cancellation wins over a
deferred steer (no steer/prompt/consume after preparation completes).

Verified: bun typecheck; hub 1043 pass, cli 2421 pass (only the pre-existing
environment-dependent runner integration suite fails locally), web 2301 and
shared 240 unchanged since their green runs.

* fix(web): reconcile stale queued rows when a steer returns invoked

Addresses the remaining Minor finding from the HAPI Bot follow-up review:
when the steer endpoint reports the message was already invoked and the
messages-consumed SSE was missed while the row was still queued, the hook
now marks the row consumed locally (mirroring useCancelQueuedMessage) so
the queued bar cannot keep a stale actionable row until the next sync.

Regression coverage: steer returning status 'invoked' reconciles the row
via markMessagesConsumed and shows no toast.

Verified: bun typecheck; web 2302 pass (hub/cli/shared unchanged since
their green runs).
2026-08-11 22:27:14 +08:00

64 lines
2.4 KiB
TypeScript

import type { MessageDeliveryMode } from '@hapi/protocol'
/**
* The one-shot UI intent associated with a composer submission. It is not
* the wire delivery mode: `default` is resolved against the current session
* state at the SessionChat boundary, while `queue` is an explicit operator
* request not to steer an in-flight Pi turn.
*/
export type ComposerSendIntent = 'default' | 'queue'
/** Structural shape shared by React's mutable ref and the runtime adapter. */
export type ComposerSendIntentRef = { current: ComposerSendIntent }
/**
* Read exactly one composer intent and immediately return the shared ref to
* the ordinary-send default. This is intentionally independent of React so
* the assistant-ui adapter can consume the value synchronously in `onNew`.
*/
export function consumeComposerSendIntent(ref?: ComposerSendIntentRef): ComposerSendIntent {
const intent = ref?.current ?? 'default'
if (ref) ref.current = 'default'
return intent
}
/**
* A retry cannot prove that the original Pi turn is still active. Preserve an
* explicit queue, but downgrade turn-scoped steer (and legacy missing mode) to
* the durable HAPI queue instead of binding the retry to a later generation.
*/
export function getRetryDeliveryMode(
deliveryMode: MessageDeliveryMode | undefined,
): 'queue' {
return deliveryMode === 'steer' ? 'queue' : (deliveryMode ?? 'queue')
}
/** Convert retry delivery into the composer's one-shot intent representation. */
export function getRestoredComposerSendIntent(
deliveryMode: MessageDeliveryMode | undefined,
): ComposerSendIntent {
return getRetryDeliveryMode(deliveryMode)
}
/**
* Resolve the web composer intent into the durable message delivery mode.
*
* Every composer submission queues by default — for every flavor. The Pi
* automatic steer (deliveryMode 'steer' while the main session is thinking)
* was removed in favor of the explicit per-queued-message Steer action
* (issue #1466), matching Codex/Claude behavior: a mid-turn message waits,
* and the operator presses Steer to deliver it into the running turn.
* Scheduled messages, scratchlist additions, and retries always queued
* already.
*/
export function resolveMessageDeliveryMode(input: {
agentFlavor: string | null | undefined
isSessionThinking: boolean
intent: ComposerSendIntent
scheduledAt?: number | null
routesToScratchlist?: boolean
}): MessageDeliveryMode {
void input
return 'queue'
}