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
95 changes: 95 additions & 0 deletions src/renderer/state/appStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
49 changes: 44 additions & 5 deletions src/renderer/state/slices/runtimeEventSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ export interface RuntimeEventSlice {
runtimeStructuralVersionByThread: Record<string, number>;
/** Frozen per-turn timing windows accumulated during the session. */
runtimeCompletedTurnsByThread: Record<string, ReadonlyArray<CompletedTurnRecord>>;
/**
* 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<string, boolean>;
/** File-backed checkpoint snapshots keyed by checkpoint item id. */
fileCheckpointsByThread: Record<string, Record<string, FileCheckpointRecord>>;
/** Completed turn file diffs keyed by the turn anchor/checkpoint item id. */
Expand Down Expand Up @@ -150,6 +160,7 @@ export const createRuntimeEventSlice: SliceCreator<RuntimeEventSlice> = (set) =>
runtimeContextByThread: {},
runtimeStructuralVersionByThread: {},
runtimeCompletedTurnsByThread: {},
runtimeOpenTurnByThread: {},
fileCheckpointsByThread: {},
fileCheckpointTurnsByThread: {},

Expand All @@ -167,7 +178,8 @@ export const createRuntimeEventSlice: SliceCreator<RuntimeEventSlice> = (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 {};
}
Expand All @@ -184,13 +196,16 @@ export const createRuntimeEventSlice: SliceCreator<RuntimeEventSlice> = (set) =>
state.runtimeStructuralVersionByThread;
const { [threadId]: _droppedTurns, ...runtimeCompletedTurnsByThread } =
state.runtimeCompletedTurnsByThread;
const { [threadId]: _droppedOpenTurn, ...runtimeOpenTurnByThread } =
state.runtimeOpenTurnByThread;
return {
runtimeItemIdsByThread,
runtimeItemsByIdByThread,
runtimeRequestsByThread,
runtimeContextByThread,
runtimeStructuralVersionByThread,
runtimeCompletedTurnsByThread,
runtimeOpenTurnByThread,
};
}),

Expand Down Expand Up @@ -393,6 +408,7 @@ type RuntimeEventState = Pick<
| "runtimeContextByThread"
| "runtimeStructuralVersionByThread"
| "runtimeCompletedTurnsByThread"
| "runtimeOpenTurnByThread"
> &
Pick<AppStoreState, "threads">;

Expand All @@ -408,6 +424,7 @@ function applyRuntimeEventsToState(
runtimeContextByThread: state.runtimeContextByThread,
runtimeStructuralVersionByThread: state.runtimeStructuralVersionByThread,
runtimeCompletedTurnsByThread: state.runtimeCompletedTurnsByThread ?? {},
runtimeOpenTurnByThread: state.runtimeOpenTurnByThread ?? {},
threads: state.threads ?? [],
};
let changed = false;
Expand Down Expand Up @@ -446,6 +463,12 @@ function reopenGuiTurnForLiveRuntimeActivity(
threadId: string,
event: RuntimeEvent,
): Partial<RuntimeEventState> {
// 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;
Expand Down Expand Up @@ -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] ?? [];
Expand Down
56 changes: 56 additions & 0 deletions src/supervisor/agents/claude/sdkSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeEvent, { type: "item.started" }> =>
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();
}
});
});
8 changes: 7 additions & 1 deletion src/supervisor/agents/claude/sdkSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down