Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion apps/web/src/domains/messaging/turn-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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");
});
});

// ---------------------------------------------------------------------------
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/domains/messaging/turn-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,23 @@ const useTurnStoreBase = create<TurnStore>()((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 -----

Expand Down Expand Up @@ -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") {
Expand Down
Loading