From e9bc8353cb10652cf17cb69b22be9e8d5a708913 Mon Sep 17 00:00:00 2001 From: Jingran <91834079+jingrandev@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:28:36 +0800 Subject: [PATCH] fix: keep user-selected terminal session active instead of auto-handoff (#1562) --- .../web/lib/ws/handlers/agent-session.test.ts | 39 +++++++++++++++++++ apps/web/lib/ws/handlers/agent-session.ts | 23 ++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index 192d5d152b..717bee7e16 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- comprehensive session-state handler tests */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { registerTaskSessionHandlers } from "./agent-session"; import type { StoreApi } from "zustand"; @@ -459,6 +460,9 @@ describe("session.state_changed → respects user-pinned session", () => { }); it("hands off when the pinned active session reaches a terminal state", () => { + // Genuine RUNNING→COMPLETED transition: previousState is non-terminal, + // so the workflow handoff should still fire even though the session + // is pinned. const store = makeStore({ tasks: { activeTaskId: "t-1", @@ -490,6 +494,41 @@ describe("session.state_changed → respects user-pinned session", () => { expect(store.getState().setActiveSessionAuto).toHaveBeenCalledWith("t-1", "s-new"); expect(store.getState().setActiveSession).not.toHaveBeenCalled(); }); + + it("does not hand off when a pinned terminal session receives a replay state_changed", () => { + // Replay: the session was already COMPLETED (previousState terminal) and + // the backend re-emits the same terminal state. The user clicked this + // session open to review it, so the pin must be honored — no handoff. + const store = makeStore({ + tasks: { + activeTaskId: "t-1", + activeSessionId: "s-old", + pinnedSessionId: "s-old", + lastSessionByTaskId: {}, + }, + taskSessions: { + items: { "s-old": { id: "s-old", task_id: "t-1", state: "COMPLETED" } }, + }, + taskSessionsByTask: { + itemsByTaskId: { + "t-1": [ + { id: "s-old", task_id: "t-1", state: "COMPLETED", started_at: "", updated_at: "" }, + { id: "s-new", task_id: "t-1", state: "STARTING", started_at: "", updated_at: "" }, + ], + }, + }, + }); + const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; + + handler({ + id: "m", + type: "notification", + action: STATE_CHANGED_EVENT, + payload: { task_id: "t-1", session_id: "s-old", new_state: "COMPLETED" }, + }); + + expect(store.getState().setActiveSessionAuto).not.toHaveBeenCalled(); + }); }); // eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 6b800507d0..c3d549bc2b 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -330,12 +330,14 @@ function inheritAgentctlStatus(state: AppState, fromSessionId: string, toSession * 2. The current active session transitions to a terminal state — hand off * to the newest non-terminal session for the same task, if any. */ +// eslint-disable-next-line max-params -- newState/previousState/wasKnownToStore are all needed by downstream branches function maybeAdoptSessionOnTransition( store: StoreApi, taskId: string, sessionId: string, newState: TaskSessionState | undefined, wasKnownToStore: boolean, + previousState: TaskSessionState | undefined, ): void { const state = store.getState(); if ( @@ -355,6 +357,18 @@ function maybeAdoptSessionOnTransition( const isActive = state.tasks.activeSessionId === sessionId; if (isActive && newState && isTerminalSessionState(newState)) { + // When the user clicked open a terminal session (e.g. to review a + // completed run), setActiveSession pins it. If the backend then + // re-emits the same terminal state_changed (a replay — the previous + // stored state was already terminal), honor the pin and do NOT hand + // off to a running session. A genuine RUNNING→COMPLETED transition + // (previousState non-terminal) still hands off normally. + if ( + state.tasks.pinnedSessionId === sessionId && + previousState && + isTerminalSessionState(previousState) + ) + return; const replacement = pickReplacementSessionId(state, taskId); if (replacement && replacement !== sessionId) { inheritAgentctlStatus(state, sessionId, replacement); @@ -566,7 +580,14 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle extractContextWindow(store, sessionId, payload); maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp); - maybeAdoptSessionOnTransition(store, taskId, sessionId, newState, !!existingSession); + maybeAdoptSessionOnTransition( + store, + taskId, + sessionId, + newState, + !!existingSession, + existingSession?.state, + ); maybeNotifySessionFailure(store, { taskId,