diff --git a/apps/web/src/domains/messaging/turn-store.test.ts b/apps/web/src/domains/messaging/turn-store.test.ts index 56d9d64bc12..a4dcd85f2ba 100644 --- a/apps/web/src/domains/messaging/turn-store.test.ts +++ b/apps/web/src/domains/messaging/turn-store.test.ts @@ -108,7 +108,7 @@ describe("USER_SEND_REQUESTED", () => { // --------------------------------------------------------------------------- describe("USER_SEND_ACCEPTED", () => { - test("sets turnId without changing phase", () => { + test("sets turnId without changing phase when already thinking", () => { const thinking = turnReducer(INITIAL_TURN_STATE, { type: "USER_SEND_REQUESTED", turnId: "temp", @@ -120,6 +120,65 @@ describe("USER_SEND_ACCEPTED", () => { expect(state.phase).toBe("thinking"); expect(state.activeTurnId).toBe("real-turn-id"); }); + + test("restores thinking phase when a stale terminal landed between request and accept", () => { + // Wild repro: + // USER_SEND_REQUESTED(turn-NEW) + // → phase=thinking, activeTurnId=turn-NEW, lastTerminalReason=null + // ← stale MESSAGE_COMPLETE from a previous turn arrives during + // the POST await + // → phase=idle, activeTurnId=null, lastTerminalReason=complete + // USER_SEND_ACCEPTED(turn-NEW) + // → must restore the in-flight shape (phase=thinking, + // lastTerminalReason=null) so the thinking indicator stays + // visible until the first assistant delta arrives. + const clobbered: TurnState = { + ...INITIAL_TURN_STATE, + phase: "idle", + activeTurnId: null, + lastTerminalReason: "complete", + }; + const state = turnReducer(clobbered, { + type: "USER_SEND_ACCEPTED", + turnId: "turn-NEW", + }); + expect(state.phase).toBe("thinking"); + expect(state.activeTurnId).toBe("turn-NEW"); + expect(state.lastTerminalReason).toBeNull(); + expect(isSending(state)).toBe(true); + expect(isThinking(state)).toBe(true); + }); + + test("restores thinking phase from errored as well", () => { + const erroredState: TurnState = { + ...INITIAL_TURN_STATE, + phase: "errored", + activeTurnId: null, + lastTerminalReason: "error", + }; + const state = turnReducer(erroredState, { + type: "USER_SEND_ACCEPTED", + turnId: "turn-NEW", + }); + expect(state.phase).toBe("thinking"); + expect(state.lastTerminalReason).toBeNull(); + }); + + test("leaves non-terminal phases alone (e.g. awaiting_user_input from a surface)", () => { + // If the user already replied to a prompt that put us in awaiting_user_input + // and the next send is being accepted, do not regress the phase to "thinking". + const awaiting: TurnState = { + ...INITIAL_TURN_STATE, + phase: "awaiting_user_input", + activeTurnId: "turn-OLD", + }; + const state = turnReducer(awaiting, { + type: "USER_SEND_ACCEPTED", + turnId: "turn-NEW", + }); + expect(state.phase).toBe("awaiting_user_input"); + expect(state.activeTurnId).toBe("turn-NEW"); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/web/src/domains/messaging/turn-store.ts b/apps/web/src/domains/messaging/turn-store.ts index e26b36b58d2..eb9821f6e80 100644 --- a/apps/web/src/domains/messaging/turn-store.ts +++ b/apps/web/src/domains/messaging/turn-store.ts @@ -314,7 +314,23 @@ const useTurnStoreBase = create()((set, get) => ({ statusText: null, })), - acceptSend: (turnId) => set({ activeTurnId: turnId }), + acceptSend: (turnId) => + set((s) => { + // The send POST has resolved — we are now waiting for the first + // assistant text delta. Phase must be "thinking" for that window + // (see `isThinking` doc comment). If a stale terminal event from + // a previous turn slipped in between `requestSend` and here during + // the POST await, phase was clobbered to "idle"/"errored" with + // `lastTerminalReason` populated. Restore the in-flight shape so + // the thinking indicator stays visible until the first delta. + const phaseIsTerminal = s.phase === "idle" || s.phase === "errored"; + return { + activeTurnId: turnId, + ...(phaseIsTerminal + ? { phase: "thinking" as const, lastTerminalReason: null } + : null), + }; + }), // ----- Streaming ----- @@ -603,8 +619,18 @@ export function turnReducer(state: TurnState, event: DomainEvent): TurnState { statusText: null, }; - case "USER_SEND_ACCEPTED": - return { ...state, activeTurnId: event.turnId }; + case "USER_SEND_ACCEPTED": { + // See `acceptSend` action for rationale. + const phaseIsTerminal = + state.phase === "idle" || state.phase === "errored"; + return { + ...state, + activeTurnId: event.turnId, + ...(phaseIsTerminal + ? { phase: "thinking" as const, lastTerminalReason: null } + : null), + }; + } case "ASSISTANT_TEXT_DELTA": if (state.phase === "idle" || state.phase === "errored") {