fix: keep user-selected terminal session active instead of auto-handoff (#1562)

This commit is contained in:
Jingran
2026-07-01 21:28:36 +08:00
committed by GitHub
parent 7c6b55ddd0
commit e9bc8353cb
2 changed files with 61 additions and 1 deletions
@@ -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
+22 -1
View File
@@ -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<AppState>,
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<AppState>): 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,