Files
hapi/cli/src/pi/steerDispatcher.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

169 lines
7.5 KiB
TypeScript

import { PiRpcTimeoutError, sendPiRpcAndWait } from './loop';
import type { PiConversationHistory } from './conversationHistory';
import type { PiSession } from './session';
import type { PiTransport } from './piTransport';
import type { PiPreparedPrompt } from './promptQueue';
export type PiPreparedSteer = PiPreparedPrompt & {
/** Active Pi generation observed when this message arrived at HAPI. */
targetStreamingGeneration: number | null;
};
type ActiveSteer = {
entry: PiPreparedSteer;
cancelled: boolean;
nativeSent: boolean;
};
/**
* Serializes explicit native Pi steers by their RPC responses. Pi lifecycle
* events intentionally do not affect this dispatcher: a steer can be accepted
* while the main prompt continues streaming, and Pi's steeringMode is only a
* native policy, not a HAPI ordering signal.
*/
export class PiSteerDispatcher {
private readonly entries: PiPreparedSteer[] = [];
private active: ActiveSteer | null = null;
private stopped = false;
constructor(private readonly options: {
session: PiSession;
transport: PiTransport;
conversationHistory: PiConversationHistory;
enqueuePrompt: (prompt: PiPreparedPrompt) => void;
onIndeterminateTimeout: (error: PiRpcTimeoutError) => void;
/** Re-attempt normal prompt pumping after local steer work drains. */
onPendingStateChange?: () => void;
}) {}
/** A prompt must wait while any earlier steer can still fall back ahead of it. */
get hasPending(): boolean {
// A locally cancelled active entry cannot reach Pi or fall back, so it
// must release the normal prompt pump even if it is still awaiting the
// runtime lock merely to finish its own cancellation path.
return !this.stopped && ((this.active !== null && !this.active.cancelled) || this.entries.length > 0);
}
enqueue(entry: PiPreparedSteer): void {
if (this.stopped) return;
this.entries.push(entry);
this.pump();
this.options.onPendingStateChange?.();
}
/** True only while the entry is still local and has not reached Pi stdin. */
cancelByLocalId(localId: string): boolean {
const index = this.entries.findIndex((entry) => entry.localId === localId);
if (index !== -1) {
this.entries.splice(index, 1);
this.options.onPendingStateChange?.();
return true;
}
if (this.active?.entry.localId === localId && !this.active.nativeSent) {
this.active.cancelled = true;
this.options.onPendingStateChange?.();
return true;
}
return false;
}
/** Stop all local work; never turn a shutdown race into a queued prompt. */
stop(): void {
this.stopped = true;
this.entries.length = 0;
if (this.active && !this.active.nativeSent) this.active.cancelled = true;
// Shutdown must never wake the ordinary prompt pump: transport error
// handlers call stop() before cleanup marks the runner terminal.
}
private pump(): void {
if (this.stopped || this.active || this.entries.length === 0) return;
const entry = this.entries.shift()!;
const active: ActiveSteer = { entry, cancelled: false, nativeSent: false };
this.active = active;
void this.dispatch(active).finally(() => {
if (this.active !== active) return;
this.active = null;
this.pump();
this.options.onPendingStateChange?.();
});
}
private async dispatch(active: ActiveSteer): Promise<void> {
try {
await this.options.session.runRuntimeMutation(async () => {
if (this.stopped || active.cancelled) return;
// A steer held behind config/history work may only reach this
// point after the original Pi turn ended. At that boundary,
// preserve normal prompt FIFO semantics instead of issuing an
// invalid/meaningless native steer.
const currentGeneration = this.options.session.currentStreamingGeneration;
if (
!this.options.session.isReady
|| currentGeneration === null
|| currentGeneration !== active.entry.targetStreamingGeneration
) {
this.options.enqueuePrompt({
message: active.entry.message,
images: active.entry.images,
outboundSequence: active.entry.outboundSequence,
...(active.entry.localId ? { localId: active.entry.localId } : {}),
});
return;
}
// Pi appends a user entry for both prompt and steer. Record it
// immediately before sending so native entry arrival remains
// FIFO-correlated even with identical message text.
this.options.conversationHistory.registerUserEntry(active.entry.localId);
active.nativeSent = true;
await sendPiRpcAndWait(this.options.session, this.options.transport, {
type: 'steer',
message: active.entry.message,
...(active.entry.images.length > 0 ? { images: active.entry.images } : {}),
});
if (this.stopped) return;
if (active.entry.localId) this.options.session.emitMessagesConsumed([active.entry.localId]);
}, { poisonOnError: (error) => error instanceof PiRpcTimeoutError });
} catch (error) {
// Transport teardown rejects outstanding RPCs. Its cleanup owns the
// session terminal state, so do not emit an error/consume event or
// degrade this steer into the prompt queue afterwards.
if (this.stopped || active.cancelled || !active.nativeSent) return;
const detail = error instanceof Error ? error.message : String(error);
this.options.conversationHistory.rejectPendingEntry(active.entry.localId);
// A deterministic native rejection means Pi never accepted the
// steer. Preserve the message by degrading it to the ordinary
// prompt FIFO (it is delivered when the agent settles) instead of
// consuming the hub row — a promoted queued message must not be
// lost just because the steer was rejected.
if (!(error instanceof PiRpcTimeoutError)) {
this.options.enqueuePrompt({
message: active.entry.message,
images: active.entry.images,
outboundSequence: active.entry.outboundSequence,
...(active.entry.localId ? { localId: active.entry.localId } : {}),
});
this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` });
return;
}
// Indeterminate timeout: Pi may or may not have accepted the
// steer. Keep the fail-closed handling (consume + escalate) rather
// than risking a duplicate delivery via the prompt FIFO.
if (active.entry.localId) {
this.options.session.emitMessagesConsumed(
[active.entry.localId],
{ clearQueuedThinkingGrace: true },
);
}
this.options.session.sendSessionEvent({ type: 'message', message: `Pi steer failed: ${detail}` });
this.options.onIndeterminateTimeout(error);
}
}
}