diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index be650980..fe718fa7 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -685,7 +685,9 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus } * history, but this one is a level the server is still looking at, so a caller that * asks after the dialog appeared is answered at once instead of waiting out its timeout * for an edge that already passed. Only when `blocked` was asked for: such a pane is also - * `idle`, and a caller that asked for `idle` alone must keep getting `idle`. + * `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by + * `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the + * session's current state. */ function currentSignalForWait( session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean }, @@ -2017,7 +2019,9 @@ export function registerSessionRoutes( // A DUPLICATE has no new turn coming, so it answers from the current state // instead of blocking for a transition that already happened. requireTransition: !duplicate, - currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined, + // The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first + // delivery raised is a level, and a retry asking for `blocked` must find it. + currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined, }); } catch (err) { if (err instanceof WaitCapacityError) { diff --git a/test/routes/session-input-wait.test.ts b/test/routes/session-input-wait.test.ts index 95080459..65ed2139 100644 --- a/test/routes/session-input-wait.test.ts +++ b/test/routes/session-input-wait.test.ts @@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { sessionWaits } from '../../src/web/session-wait-registry.js'; -import { MAX_WAIT_MS } from '../../src/config/agent-wait.js'; +import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js'; // Distinct per file on purpose: the three wait suites share the process-wide // `sessionWaits` singleton, so a common id let one file's leftover waiter be counted @@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => { expect(body.data.status).toBe('busy'); }); + it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => { + // A screen-read dialog is a level, not an edge: the original delivery may have raised + // it before the caller's connection dropped, and the retry must not wait out its + // timeout for an edge that already passed. + const { app, ctx } = await harness(); + const session = ctx.sessions.get(SESSION_ID)! as unknown as { + mode: string; + status: string; + screenBlocked?: boolean; + writeBuffer: string[]; + }; + session.mode = 'codex'; + await send(app, { input: 'first', clientId: 'c-codex', seq: 1 }); + session.status = 'idle'; + session.screenBlocked = true; + + const replay = await send(app, { + input: 'first', + clientId: 'c-codex', + seq: 1, + wait: 'blocked', + waitTimeout: MIN_WAIT_MS, + }); + const body = replay.json(); + + expect(body.data.duplicate).toBe(true); + expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false }); + + // ...and only to a caller that asked for it: such a pane is idle too. + const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' }); + expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true }); + }); + it('gives the dedup seq back when a full waiter pool rejects the request', async () => { // Otherwise the caller's retry is refused as a duplicate and the input vanishes. const { app, ctx } = await harness();