mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(steer): persist indeterminate outcomes without replay * fix(steer): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(steer): keep ACK failures held and reconcile busy cancel * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(store): reserve schema v25 for steer delivery state * fix(steer): keep held cancel state and notify requeue * fix(steer): release explicitly cancelled unknown reservations * fix(codex): reject cancelled reservations before native steer * fix(codex): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(steer): own abandoned app-server lifecycle and consume races * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones
This commit is contained in:
@@ -57,6 +57,12 @@ const harness = vi.hoisted(() => ({
|
||||
failNextCompact: false,
|
||||
deferCompactCompletion: false,
|
||||
deferThreadStatusNotifications: false,
|
||||
steerTurnParams: [] as Array<Record<string, unknown>>,
|
||||
steerDispatchError: null as Error | null,
|
||||
steerCompletionError: null as Error | null,
|
||||
readThreadParams: [] as Array<Record<string, unknown>>,
|
||||
readThreadError: null as Error | null,
|
||||
readThreadResponse: { thread: { turns: [] as unknown[] } } as { thread: { turns?: Array<{ items?: Array<Record<string, unknown>> }> } },
|
||||
emitStaleTaskCompleteAfterRetry: false,
|
||||
emitStaleTaskFailedAfterRetry: false,
|
||||
emitStaleThreadStatusFailureAfterRetry: false,
|
||||
@@ -90,12 +96,21 @@ const harness = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('./codexAppServerClient', () => {
|
||||
const INDETERMINATE_SYMBOL = Symbol('codex-app-server-indeterminate');
|
||||
class MockCodexAppServerClient {
|
||||
private notificationHandler: ((method: string, params: unknown) => void) | null = null;
|
||||
private stderrHandler: ((text: string) => void) | null = null;
|
||||
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
isConnected(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async initialize(params: unknown): Promise<{ protocolVersion: number }> {
|
||||
harness.initializeCalls.push(params);
|
||||
return { protocolVersion: 1 };
|
||||
@@ -106,6 +121,8 @@ vi.mock('./codexAppServerClient', () => {
|
||||
harness.dispatchNotification = handler;
|
||||
}
|
||||
|
||||
setTransportAbandonedHandler(_handler: (() => void) | null): void {}
|
||||
|
||||
setStderrHandler(handler: ((text: string) => void) | null): void {
|
||||
this.stderrHandler = handler;
|
||||
}
|
||||
@@ -195,6 +212,33 @@ vi.mock('./codexAppServerClient', () => {
|
||||
return { goal: harness.goal };
|
||||
}
|
||||
|
||||
async steerTurn(params?: { threadId?: string; input?: unknown[]; expectedTurnId?: string; clientUserMessageId?: string }): Promise<{
|
||||
dispatched: Promise<void>;
|
||||
completed: Promise<unknown>;
|
||||
}> {
|
||||
harness.steerTurnParams.push((params ?? {}) as Record<string, unknown>);
|
||||
if (harness.steerDispatchError) {
|
||||
return {
|
||||
dispatched: Promise.reject(harness.steerDispatchError),
|
||||
completed: Promise.reject(harness.steerDispatchError)
|
||||
};
|
||||
}
|
||||
return {
|
||||
dispatched: Promise.resolve(),
|
||||
completed: harness.steerCompletionError
|
||||
? Promise.reject(harness.steerCompletionError)
|
||||
: Promise.resolve({ turnId: 'steered-turn' })
|
||||
};
|
||||
}
|
||||
|
||||
async readThread(params?: { threadId?: string; includeTurns?: boolean }): Promise<unknown> {
|
||||
harness.readThreadParams.push((params ?? {}) as Record<string, unknown>);
|
||||
if (harness.readThreadError) {
|
||||
throw harness.readThreadError;
|
||||
}
|
||||
return harness.readThreadResponse;
|
||||
}
|
||||
|
||||
async clearThreadGoal(params?: { threadId?: string }): Promise<{ cleared: boolean }> {
|
||||
harness.goalClearCalls.push(params ?? {});
|
||||
const cleared = harness.goal !== null;
|
||||
@@ -1028,7 +1072,13 @@ vi.mock('./codexAppServerClient', () => {
|
||||
async disconnect(): Promise<void> {}
|
||||
}
|
||||
|
||||
return { CodexAppServerClient: MockCodexAppServerClient };
|
||||
return {
|
||||
CodexAppServerClient: MockCodexAppServerClient,
|
||||
INDETERMINATE_SYMBOL,
|
||||
isIndeterminateError: (error: unknown) => Boolean(
|
||||
error && (error as Record<symbol, unknown>)[INDETERMINATE_SYMBOL] === true
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./utils/buildHapiMcpBridge', () => ({
|
||||
@@ -1043,7 +1093,9 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
import { codexRemoteLauncher } from './codexRemoteLauncher';
|
||||
import { codexRemoteLauncher, isCurrentSteerHandler } from './codexRemoteLauncher';
|
||||
import { INDETERMINATE_SYMBOL } from './codexAppServerClient';
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
||||
|
||||
type FakeAgentState = {
|
||||
requests: Record<string, unknown>;
|
||||
@@ -1061,7 +1113,8 @@ function createMode(): EnhancedMode {
|
||||
function createSessionStub(
|
||||
messages = ['hello from launcher test'],
|
||||
mode = createMode(),
|
||||
isolateMessages = false
|
||||
isolateMessages = false,
|
||||
closeQueue = true
|
||||
) {
|
||||
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
|
||||
messages.forEach((message, index) => {
|
||||
@@ -1073,7 +1126,9 @@ function createSessionStub(
|
||||
queue.push(message, mode);
|
||||
}
|
||||
});
|
||||
queue.close();
|
||||
if (closeQueue) {
|
||||
queue.close();
|
||||
}
|
||||
|
||||
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
const codexMessages: unknown[] = [];
|
||||
@@ -1111,7 +1166,10 @@ function createSessionStub(
|
||||
},
|
||||
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
|
||||
sessionEvents.push(event);
|
||||
}
|
||||
},
|
||||
emitMessagesConsumed: vi.fn(),
|
||||
emitSteerIndeterminate: vi.fn(),
|
||||
setSteerDeliveryState: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
const session = {
|
||||
@@ -1174,6 +1232,8 @@ function createSessionStub(
|
||||
foundSessionIds,
|
||||
resetThreadCalls,
|
||||
rpcHandlers,
|
||||
emitMessagesConsumed: client.emitMessagesConsumed,
|
||||
emitSteerIndeterminate: client.emitSteerIndeterminate,
|
||||
setPermissionMode: (nextMode: EnhancedMode['permissionMode']) => {
|
||||
currentPermissionMode = nextMode;
|
||||
},
|
||||
@@ -1186,6 +1246,147 @@ function createSessionStub(
|
||||
}
|
||||
|
||||
describe('codexRemoteLauncher', () => {
|
||||
it('invalidates queued steer handlers after abort or cleanup', () => {
|
||||
expect(isCurrentSteerHandler(3, 3, false)).toBe(true);
|
||||
expect(isCurrentSteerHandler(4, 3, false)).toBe(false);
|
||||
expect(isCurrentSteerHandler(3, 3, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('steers a queued message into the active turn and acks on dispatch', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
expect(result).toEqual({ steered: true });
|
||||
expect(harness.steerTurnParams).toHaveLength(1);
|
||||
expect(harness.steerTurnParams[0]).toMatchObject({
|
||||
expectedTurnId: 'turn-1',
|
||||
clientUserMessageId: 'local-1'
|
||||
});
|
||||
await vi.waitFor(() => expect(emitMessagesConsumed).toHaveBeenCalledWith(['local-1'], { steered: true }));
|
||||
// The suppressed turn never completes, so the launcher stays busy;
|
||||
// close the queue and leave the run promise unawaited.
|
||||
session.queue.close();
|
||||
});
|
||||
|
||||
it('restores the row when the app-server explicitly rejects after dispatch', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
// Plain Error without the indeterminate marker = definite JSON-RPC
|
||||
// rejection: the instruction was not accepted, so restoring is safe.
|
||||
harness.steerCompletionError = new Error('turn/steer not allowed here');
|
||||
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
// Explicit rejection surfaces as failed (not a false steered), and the
|
||||
// row is restored so the normal turn/start path delivers it later.
|
||||
expect(result).toEqual({ steered: false, error: 'turn/steer not allowed here' });
|
||||
await vi.waitFor(() => expect(session.queue.cancelByLocalId('local-1')).toBe(true));
|
||||
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
||||
session.queue.close();
|
||||
});
|
||||
|
||||
it('holds an indeterminate steer for explicit retry or cancel', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
const indeterminate = new Error('Codex app-server disconnected');
|
||||
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
||||
harness.steerCompletionError = indeterminate;
|
||||
// Reconciliation cannot read the thread either. The row is held out of
|
||||
// automatic replay and the user can explicitly retry or cancel it.
|
||||
harness.readThreadError = new Error('app-server unreachable');
|
||||
const { session, rpcHandlers, emitMessagesConsumed, emitSteerIndeterminate } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
// Transport failure after dispatch: report reconciliation, persist the
|
||||
// ambiguous state, and remove it from the automatic queue.
|
||||
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
||||
expect(emitSteerIndeterminate).toHaveBeenCalledWith(['local-1']);
|
||||
expect(session.queue.cancelByLocalId('local-1')).toBe(true);
|
||||
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
||||
session.queue.close();
|
||||
});
|
||||
|
||||
it('reconciles an indeterminate steer to accepted once the thread shows the message', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
const indeterminate = new Error('Codex app-server disconnected');
|
||||
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
||||
harness.steerCompletionError = indeterminate;
|
||||
harness.readThreadResponse = {
|
||||
thread: {
|
||||
turns: [{
|
||||
items: [{ type: 'userMessage', clientId: 'local-1', text: 'steer me' }]
|
||||
}]
|
||||
}
|
||||
};
|
||||
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
||||
// The scheduled reconciliation (1s timer) reads the thread, finds the
|
||||
// message, commits the row and fires the badge — no restore, no
|
||||
// duplicate.
|
||||
await vi.waitFor(() => expect(emitMessagesConsumed).toHaveBeenCalledWith(['local-1'], { steered: true }), { timeout: 5_000 });
|
||||
// Row committed and consumed — the tombstone blocks a stale retry.
|
||||
expect(session.queue.cancelByLocalId('local-1')).toBe('consumed');
|
||||
session.queue.close();
|
||||
});
|
||||
|
||||
it('reconciles when stdin dispatch fails indeterminately', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
const indeterminate = new Error('stdin callback timed out');
|
||||
Object.defineProperty(indeterminate, INDETERMINATE_SYMBOL, { value: true });
|
||||
harness.steerDispatchError = indeterminate;
|
||||
harness.readThreadError = new Error('app-server unreachable');
|
||||
const { session, rpcHandlers, emitMessagesConsumed, emitSteerIndeterminate } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
expect(result).toEqual({ steered: false, error: 'Steer outcome is being reconciled' });
|
||||
expect(emitSteerIndeterminate).toHaveBeenCalledWith(['local-1']);
|
||||
expect(session.queue.cancelByLocalId('local-1')).toBe(true);
|
||||
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
||||
session.queue.close();
|
||||
});
|
||||
|
||||
it('restores the queued row when stdin dispatch fails', async () => {
|
||||
harness.suppressTurnCompletion = true;
|
||||
harness.steerDispatchError = new Error('stdin closed');
|
||||
const { session, rpcHandlers, emitMessagesConsumed } = createSessionStub(['first'], createMode(), false, false);
|
||||
const runPromise = codexRemoteLauncher(session as never);
|
||||
await vi.waitFor(() => expect(harness.startTurnThreadIds.length).toBe(1));
|
||||
|
||||
session.queue.push('steer me', createMode(), 'local-1');
|
||||
const handler = rpcHandlers.get(RPC_METHODS.SteerQueuedMessage)!;
|
||||
const result = await handler({ localId: 'local-1' });
|
||||
|
||||
expect(result).toEqual({ steered: false, error: 'stdin closed' });
|
||||
await vi.waitFor(() => expect(session.queue.cancelByLocalId('local-1')).toBe(true));
|
||||
expect(emitMessagesConsumed).not.toHaveBeenCalledWith(['local-1'], { steered: true });
|
||||
session.queue.close();
|
||||
});
|
||||
afterEach(() => {
|
||||
harness.notifications = [];
|
||||
harness.dispatchNotification = null;
|
||||
@@ -1241,6 +1442,12 @@ describe('codexRemoteLauncher', () => {
|
||||
harness.failNextCompact = false;
|
||||
harness.deferCompactCompletion = false;
|
||||
harness.deferThreadStatusNotifications = false;
|
||||
harness.steerTurnParams = [];
|
||||
harness.steerDispatchError = null;
|
||||
harness.steerCompletionError = null;
|
||||
harness.readThreadParams = [];
|
||||
harness.readThreadError = null;
|
||||
harness.readThreadResponse = { thread: { turns: [] } };
|
||||
harness.emitStaleTaskCompleteAfterRetry = false;
|
||||
harness.emitStaleTaskFailedAfterRetry = false;
|
||||
harness.emitStaleThreadStatusFailureAfterRetry = false;
|
||||
|
||||
Reference in New Issue
Block a user