diff --git a/src/renderer/state/appStore.test.ts b/src/renderer/state/appStore.test.ts index dbeee524..c3eb815a 100644 --- a/src/renderer/state/appStore.test.ts +++ b/src/renderer/state/appStore.test.ts @@ -978,6 +978,101 @@ describe("appStore runtime config sync", () => { }); }); + it("does not reopen a settled GUI turn when trailing activity arrives after turn.completed", () => { + const project = useAppStore.getState().addProject({ + kind: "windows", + path: "C:\\repo", + }); + const thread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "claude", + config: { model: "m" }, + prompt: "a", + presentationMode: "gui", + }); + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "turn.started", + threadId: thread.id, + turnId: "turn-1", + }); + // An item that stays open across the turn boundary, mirroring the persistent + // plan/todo item that `closeClaudeOpenItems` deliberately leaves open. + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: "plan-1", + itemType: "reasoning", + }); + useAppStore.getState().updateThreadRuntime(thread.id, { + status: "working", + attention: "working", + canResumeWithConfig: true, + }); + // Turn settles: turn.completed is flushed before the idle status. + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "turn.completed", + threadId: thread.id, + turnId: "turn-1", + state: "completed", + }); + useAppStore.getState().updateThreadRuntime(thread.id, { + status: "idle", + attention: "none", + canResumeWithConfig: true, + }); + expect(useAppStore.getState().threads[0]?.status).toBe("idle"); + + // A trailing live event for the already-settled turn lands after idle (the + // post-idle IPC race). It must NOT flip the thread back to "working". + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.updated", + threadId: thread.id, + itemId: "plan-1", + payload: {}, + }); + + expect(useAppStore.getState().threads[0]?.status).toBe("idle"); + }); + + it("still reopens a GUI turn for live activity while the turn is still open", () => { + const project = useAppStore.getState().addProject({ + kind: "windows", + path: "C:\\repo", + }); + const thread = useAppStore.getState().createThread({ + projectId: project.id, + agentKind: "claude", + config: { model: "m" }, + prompt: "a", + presentationMode: "gui", + }); + // Turn is open (turn.started, no turn.completed yet) but a premature idle + // arrives — the safety net must still reopen when real activity follows. + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "turn.started", + threadId: thread.id, + turnId: "turn-1", + }); + useAppStore.getState().updateThreadRuntime(thread.id, { + status: "idle", + attention: "none", + canResumeWithConfig: true, + }); + expect(useAppStore.getState().threads[0]?.status).toBe("idle"); + + useAppStore.getState().applyRuntimeEvent(thread.id, { + type: "item.started", + threadId: thread.id, + itemId: "assistant-1", + itemType: "assistant_message", + }); + + expect(useAppStore.getState().threads[0]).toMatchObject({ + status: "working", + attention: "working", + }); + }); + it("does not add sub-second completed turns", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z")); diff --git a/src/renderer/state/slices/runtimeEventSlice.ts b/src/renderer/state/slices/runtimeEventSlice.ts index 691a8a81..4ad830a3 100644 --- a/src/renderer/state/slices/runtimeEventSlice.ts +++ b/src/renderer/state/slices/runtimeEventSlice.ts @@ -92,6 +92,16 @@ export interface RuntimeEventSlice { runtimeStructuralVersionByThread: Record; /** Frozen per-turn timing windows accumulated during the session. */ runtimeCompletedTurnsByThread: Record>; + /** + * Tracks whether the runtime event stream's current turn is open per thread: + * `true` after `turn.started`, `false` after `turn.completed`. Gates + * `reopenGuiTurnForLiveRuntimeActivity` so trailing runtime events that land + * AFTER a turn's `turn.completed` (and its `idle` status) cannot flip a + * settled GUI thread back to "working". Absent (`undefined`) means we have no + * turn-boundary evidence yet, so reopen stays permitted (premature-idle + * safety net). See reopenGuiTurnForLiveRuntimeActivity. + */ + runtimeOpenTurnByThread: Record; /** File-backed checkpoint snapshots keyed by checkpoint item id. */ fileCheckpointsByThread: Record>; /** Completed turn file diffs keyed by the turn anchor/checkpoint item id. */ @@ -150,6 +160,7 @@ export const createRuntimeEventSlice: SliceCreator = (set) => runtimeContextByThread: {}, runtimeStructuralVersionByThread: {}, runtimeCompletedTurnsByThread: {}, + runtimeOpenTurnByThread: {}, fileCheckpointsByThread: {}, fileCheckpointTurnsByThread: {}, @@ -167,7 +178,8 @@ export const createRuntimeEventSlice: SliceCreator = (set) => !(threadId in state.runtimeRequestsByThread) && !(threadId in state.runtimeContextByThread) && !(threadId in state.runtimeStructuralVersionByThread) && - !(threadId in state.runtimeCompletedTurnsByThread) + !(threadId in state.runtimeCompletedTurnsByThread) && + !(threadId in state.runtimeOpenTurnByThread) ) { return {}; } @@ -184,6 +196,8 @@ export const createRuntimeEventSlice: SliceCreator = (set) => state.runtimeStructuralVersionByThread; const { [threadId]: _droppedTurns, ...runtimeCompletedTurnsByThread } = state.runtimeCompletedTurnsByThread; + const { [threadId]: _droppedOpenTurn, ...runtimeOpenTurnByThread } = + state.runtimeOpenTurnByThread; return { runtimeItemIdsByThread, runtimeItemsByIdByThread, @@ -191,6 +205,7 @@ export const createRuntimeEventSlice: SliceCreator = (set) => runtimeContextByThread, runtimeStructuralVersionByThread, runtimeCompletedTurnsByThread, + runtimeOpenTurnByThread, }; }), @@ -393,6 +408,7 @@ type RuntimeEventState = Pick< | "runtimeContextByThread" | "runtimeStructuralVersionByThread" | "runtimeCompletedTurnsByThread" + | "runtimeOpenTurnByThread" > & Pick; @@ -408,6 +424,7 @@ function applyRuntimeEventsToState( runtimeContextByThread: state.runtimeContextByThread, runtimeStructuralVersionByThread: state.runtimeStructuralVersionByThread, runtimeCompletedTurnsByThread: state.runtimeCompletedTurnsByThread ?? {}, + runtimeOpenTurnByThread: state.runtimeOpenTurnByThread ?? {}, threads: state.threads ?? [], }; let changed = false; @@ -446,6 +463,12 @@ function reopenGuiTurnForLiveRuntimeActivity( threadId: string, event: RuntimeEvent, ): Partial { + // Once a turn has completed (`turn.completed` -> open === false), trailing + // runtime events for that turn can legitimately arrive after its `idle` + // status on the single FIFO IPC wire. Those must not flip the settled GUI + // thread back to "working". A genuinely premature idle (no `turn.completed` + // yet -> open !== false) still reopens, preserving the safety net. + if (state.runtimeOpenTurnByThread[threadId] === false) return {}; if (!isLiveAssistantActivity(state, threadId, event)) return {}; let changed = false; let nextCompletedTurns = state.runtimeCompletedTurnsByThread; @@ -551,14 +574,30 @@ function applyRuntimeEventToRuntimeState( switch (event.type) { case "session.started": case "session.exited": - case "turn.started": case "warning": // No item state to mutate. Status flows through the existing thread-state channel. return {}; - case "turn.completed": - if (event.state !== "interrupted" && event.state !== "cancelled") return {}; - return pruneTrailingInterruptedReasoningItems(state, threadId); + case "turn.started": + // Mark the runtime turn open so live activity may (re)open the GUI turn. + // No item state to mutate; status flows through the thread-state channel. + if (state.runtimeOpenTurnByThread[threadId] === true) return {}; + return { + runtimeOpenTurnByThread: { ...state.runtimeOpenTurnByThread, [threadId]: true }, + }; + + case "turn.completed": { + // Mark the runtime turn closed. Trailing live events that land after this + // (e.g. on the persistent plan item, after the turn's `idle` status) must + // NOT reopen the settled GUI turn — that left a stale "working" until the + // next thread-switch snapshot reconcile. + const closeTurnPatch = + state.runtimeOpenTurnByThread[threadId] === false + ? {} + : { runtimeOpenTurnByThread: { ...state.runtimeOpenTurnByThread, [threadId]: false } }; + if (event.state !== "interrupted" && event.state !== "cancelled") return closeTurnPatch; + return { ...closeTurnPatch, ...pruneTrailingInterruptedReasoningItems(state, threadId) }; + } case "item.started": { const existingIds = state.runtimeItemIdsByThread[threadId] ?? []; diff --git a/src/supervisor/agents/claude/sdkSession.test.ts b/src/supervisor/agents/claude/sdkSession.test.ts index bf6c3f1e..2d2d6674 100644 --- a/src/supervisor/agents/claude/sdkSession.test.ts +++ b/src/supervisor/agents/claude/sdkSession.test.ts @@ -963,4 +963,60 @@ describe("ClaudeSdkSession", () => { await session.dispose(); }); + + it("stops the goal-tracking poller after an interrupted turn while keeping the goal active", async () => { + vi.useFakeTimers(); + try { + const fake = createFakeQuery(); + mockSdk.query.mockReturnValue(fake.runtime); + const runtimeEvents: RuntimeEvent[] = []; + const session = await ClaudeSdkSession.create({ + threadId: "thread-claude-goal-interrupt-timer", + projectLocation, + config, + presentationMode: "gui", + }); + session.setListener({ + onRuntimeEvent: (event) => runtimeEvents.push(event), + onUpdate: () => {}, + onError: () => {}, + onClose: () => {}, + }); + + const openedSessionId = await session.openThread(config); + await session.startTurn("/goal fix the bug", config); + + // Stop/steer mid-turn: interrupt, then the interrupted result settles it. + await session.interruptTurn(); + fake.emitMessage({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["execution stopped by user"], + session_id: openedSessionId, + } as unknown as SDKMessage); + await vi.advanceTimersByTimeAsync(0); + + // The goal is kept active across the stop (not completed/cleared). + const goalItemId = runtimeEvents.find( + (event): event is Extract => + event.type === "item.started" && event.itemType === "goal", + )?.itemId; + expect( + runtimeEvents + .filter((event) => event.type === "item.updated" && event.itemId === goalItemId) + .at(-1), + ).toMatchObject({ payload: { status: "active" } }); + + // The 15s goal-tracking interval must have stopped — advancing well past + // it produces no further context-usage polls (previously it leaked). + const pollsAfterStop = fake.getContextUsage.mock.calls.length; + await vi.advanceTimersByTimeAsync(45_000); + expect(fake.getContextUsage.mock.calls.length).toBe(pollsAfterStop); + + await session.dispose(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index ce379675..25b14efc 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -1131,7 +1131,13 @@ export class ClaudeSdkSession implements StructuredSessionHandle { } this.currentTurnAssistantUuid = undefined; this.currentTurnInFlight = false; - if (!wasInterrupted) this.stopGoalTracking(); + // Stop the 15s goal-tracking poller on every turn end — including + // interrupts and steers — so it does not keep firing context-usage + // round-trips while the thread sits idle. The goal itself is NOT cleared + // here: it stays active (completeActiveGoalEvents only completes it on a + // clean turn end, and `/clear` clears it explicitly), and the next turn + // restarts tracking via startGoalTracking(). + this.stopGoalTracking(); this.emitUpdate({ status: failed ? "error" : "idle", attention: failed ? "error" : "none",