fix: stabilize task switch flow and session recovery (#818)

This commit is contained in:
Carlos Florêncio
2026-05-04 18:53:33 +01:00
committed by GitHub
parent 39b9369ae1
commit c042050469
21 changed files with 665 additions and 146 deletions
+3
View File
@@ -120,6 +120,9 @@ func appendAgentctlStatusMessage(
sessionIDPayloadKey: sessionID,
"agent_execution_id": execution.ID,
}
if execution.TaskEnvironmentID != "" {
payload["task_environment_id"] = execution.TaskEnvironmentID
}
action := ws.ActionSessionAgentctlStarting
if execution.GetWorkspaceStream() != nil {
action = ws.ActionSessionAgentctlReady
@@ -23,13 +23,14 @@ type AgentEventPayload struct {
// AgentctlEventPayload is the payload for agentctl lifecycle events (starting, ready, error).
type AgentctlEventPayload struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentExecutionID string `json:"agent_execution_id"`
ErrorMessage string `json:"error_message,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
AgentExecutionID string `json:"agent_execution_id"`
ErrorMessage string `json:"error_message,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
}
// ACPSessionCreatedPayload is the payload when an ACP session is created.
@@ -80,13 +80,14 @@ func (p *EventPublisher) PublishAgentctlEvent(ctx context.Context, eventType str
}
payload := AgentctlEventPayload{
TaskID: execution.TaskID,
SessionID: execution.SessionID,
AgentExecutionID: execution.ID,
ErrorMessage: errMsg,
WorktreeID: worktreeID,
WorktreePath: execution.WorkspacePath,
WorktreeBranch: worktreeBranch,
TaskID: execution.TaskID,
SessionID: execution.SessionID,
TaskEnvironmentID: execution.TaskEnvironmentID,
AgentExecutionID: execution.ID,
ErrorMessage: errMsg,
WorktreeID: worktreeID,
WorktreePath: execution.WorkspacePath,
WorktreeBranch: worktreeBranch,
}
event := bus.NewEvent(eventType, "agent-manager", payload)
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-desktop-layout";
/**
* Regression: the generic "chat" placeholder dockview panel used to fall back
* to the literal "Agent" label even when the active session's agent profile
* was loaded (e.g. "Opus"). The bug was a stale `isSessionTab && agentLabel`
* gate inside `useChatSessionTitle` that suppressed the agent label for the
* non-session-scoped placeholder. The pure resolver below is the single place
* the gate would have to be re-introduced, so this test pins the behavior.
*/
describe("resolveChatPanelTitle", () => {
it("returns the agent label when one is provided", () => {
expect(resolveChatPanelTitle("Opus")).toBe("Opus");
});
it("falls back to the generic 'Agent' label when null", () => {
expect(resolveChatPanelTitle(null)).toBe(CHAT_PANEL_FALLBACK_LABEL);
});
it("falls back to the generic 'Agent' label when undefined", () => {
expect(resolveChatPanelTitle(undefined)).toBe(CHAT_PANEL_FALLBACK_LABEL);
});
it("falls back to the generic 'Agent' label when the agent label is empty", () => {
expect(resolveChatPanelTitle("")).toBe(CHAT_PANEL_FALLBACK_LABEL);
});
it("uses the agent label verbatim — does not coerce or relabel valid names", () => {
for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) {
expect(resolveChatPanelTitle(name)).toBe(name);
}
});
});
@@ -201,7 +201,13 @@ function SidebarContent({ panelId }: { panelId: string }) {
return <TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} />;
}
function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) {
export const CHAT_PANEL_FALLBACK_LABEL = "Agent";
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string {
return agentLabel || CHAT_PANEL_FALLBACK_LABEL;
}
function useChatSessionTitle(panelId: string, sessionId: string | null) {
const agentLabel = useAppStore((state) => {
if (!sessionId) return null;
const session = state.taskSessions.items[sessionId];
@@ -214,12 +220,8 @@ function useChatSessionTitle(panelId: string, sessionId: string | null, isSessio
return parts[1] || parts[0] || profile.label;
});
useEffect(() => {
let label = "Agent";
if (isSessionTab && agentLabel) {
label = agentLabel;
}
setPanelTitle(panelId, label);
}, [panelId, isSessionTab, agentLabel]);
setPanelTitle(panelId, resolveChatPanelTitle(agentLabel));
}, [panelId, agentLabel]);
}
function ChatContent({ panelId, params }: { panelId: string; params: Record<string, unknown> }) {
@@ -230,7 +232,7 @@ function ChatContent({ panelId, params }: { panelId: string; params: Record<stri
const isPassthrough = useAppStore((state) =>
sessionId ? state.taskSessions.items[sessionId]?.is_passthrough === true : false,
);
useChatSessionTitle(panelId, sessionId, !!paramSessionId);
useChatSessionTitle(panelId, sessionId);
if (isPassthrough) {
return (
@@ -405,7 +407,6 @@ function useEnvSwitchCleanup(effectiveSessionId: string | null, effectiveEnvId:
const prevEnvRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
const newEnvId = effectiveEnvId;
if (prevEnvRef.current === undefined) {
prevEnvRef.current = newEnvId;
return;
@@ -138,3 +138,52 @@ describe("sanitizeLayout - size validation", () => {
expect(Object.keys(result.panels)).not.toContain("unknown");
});
});
describe("sanitizeLayout - session panel handling", () => {
const SESSION_PANEL_ID = "session:abc-123";
function buildLayoutWithSession() {
const layout = buildLayout();
layout.grid.root.data[1].data.views = ["chat", SESSION_PANEL_ID];
layout.panels = {
...layout.panels,
// @ts-expect-error - injecting a session panel without a matching valid component
[SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "session-panel" },
};
return layout;
}
it("keeps session:* panels by default (per-env restore)", () => {
const result = sanitizeLayout(buildLayoutWithSession(), VALID_COMPONENTS);
expect(result).not.toBeNull();
expect(Object.keys(result.panels)).toContain(SESSION_PANEL_ID);
});
it("strips session:* panels when stripSessionPanels=true (global fallback)", () => {
const result = sanitizeLayout(buildLayoutWithSession(), VALID_COMPONENTS, {
stripSessionPanels: true,
});
expect(result).not.toBeNull();
expect(Object.keys(result.panels)).not.toContain(SESSION_PANEL_ID);
// The non-session panels stay so the rest of the layout is still usable.
expect(Object.keys(result.panels)).toContain("chat");
expect(Object.keys(result.panels)).toContain("files");
});
it("strips session:* panels even when contentComponent is a valid component (e.g. 'chat')", () => {
// dockview serializes panels added via api.addPanel({ component: "chat" })
// with contentComponent: "chat", which is in VALID_COMPONENTS. The strip
// logic must key off the panel id, not the component name.
const layout = buildLayout();
layout.grid.root.data[1].data.views = ["chat", SESSION_PANEL_ID];
layout.panels = {
...layout.panels,
// @ts-expect-error - injecting a session panel with the chat component
[SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "chat" },
};
const result = sanitizeLayout(layout, VALID_COMPONENTS, { stripSessionPanels: true });
expect(result).not.toBeNull();
expect(Object.keys(result.panels)).not.toContain(SESSION_PANEL_ID);
expect(Object.keys(result.panels)).toContain("chat");
});
});
@@ -6,17 +6,31 @@ import { getEnvLayout, getEnvMaximizeState } from "@/lib/local-storage";
const LAYOUT_STORAGE_KEY = "dockview-layout-v1";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function sanitizeLayout(layout: any, validComponents: Set<string>): any {
/* eslint-disable @typescript-eslint/no-explicit-any */
export function sanitizeLayout(
layout: any,
validComponents: Set<string>,
options: { stripSessionPanels?: boolean } = {},
): any {
if (!isLayoutShapeHealthy(layout)) return null;
const invalidIds = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validPanels: Record<string, any> = {};
for (const [id, panel] of Object.entries(layout.panels)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const comp = (panel as any).contentComponent;
if (comp && (validComponents.has(comp) || id.startsWith("session:"))) {
// Session panels are scoped to a specific environment; when restoring the
// global fallback (no envId yet), they belong to the previous task and
// would leak in as duplicate tabs. Strip them in that case. The session
// check must happen before component-validity, since session panels are
// serialized with contentComponent: "chat" (a valid component) and would
// otherwise short-circuit the strip guard.
if (id.startsWith("session:")) {
if (options.stripSessionPanels) {
invalidIds.add(id);
} else {
validPanels[id] = panel;
}
} else if (comp && validComponents.has(comp)) {
validPanels[id] = panel;
} else {
invalidIds.add(id);
@@ -25,7 +39,6 @@ export function sanitizeLayout(layout: any, validComponents: Set<string>): any {
if (invalidIds.size === 0) return layout;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function cleanNode(node: any): any {
if (node.type === "leaf") {
const views = (node.data.views as string[]).filter((v) => !invalidIds.has(v));
@@ -34,7 +47,6 @@ export function sanitizeLayout(layout: any, validComponents: Set<string>): any {
return { ...node, data: { ...node.data, views, activeView } };
}
if (node.type === "branch") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const children = (node.data as any[]).map(cleanNode).filter(Boolean);
if (children.length === 0) return null;
return { ...node, data: children };
@@ -51,6 +63,7 @@ export function sanitizeLayout(layout: any, validComponents: Set<string>): any {
panels: validPanels,
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */
function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void {
const savedMax = envId ? getEnvMaximizeState(envId) : null;
@@ -97,15 +110,13 @@ export function tryRestoreLayout(
const envLayout = getEnvLayout(currentEnvId);
if (envLayout) {
const sanitized = sanitizeLayout(envLayout, validComponents);
if (!sanitized) {
return false;
}
if (!sanitized) return false;
api.fromJSON(sanitized as SerializedDockview);
applyFixupsWithMaximize(api, currentEnvId);
return true;
}
} catch {
// Per-env restore failed, try global
// fall through to maximize-only / global fallback
}
if (tryRestoreMaximizeOnly(api, currentEnvId)) return true;
}
@@ -114,14 +125,16 @@ export function tryRestoreLayout(
try {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (saved) {
const layout = sanitizeLayout(JSON.parse(saved), validComponents);
const layout = sanitizeLayout(JSON.parse(saved), validComponents, {
stripSessionPanels: true,
});
if (!layout) return false;
api.fromJSON(layout);
useDockviewStore.setState(applyLayoutFixups(api));
return true;
}
} catch {
// Global restore failed, build default
// fall through to default-build
}
}
@@ -373,7 +373,7 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
return () => {
clearRetryTimer();
};
}, [clearRetryTimer, loadTree, effectiveResetKey]);
}, [clearRetryTimer, loadTree, effectiveResetKey, sessionId]);
// Fire the initial load on the waiting → ready transition. `loadState` is
// read via a ref so a failed load (which sets state back to "waiting") does
@@ -385,7 +385,7 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
if (loadStateRef.current === "loading") return;
if (loadStateRef.current === "loaded" && treeRef.current) return;
void loadTree({ resetRetry: true });
}, [agentctlStatus.isReady, loadTree]);
}, [agentctlStatus.isReady, loadTree, sessionId]);
useEffect(() => {
if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return;
@@ -19,11 +19,13 @@ export function useSessionAgentctl(sessionId: string | null) {
return client.subscribeSession(session.id);
}, [session?.id, connectionStatus]);
const isReady = status?.status === "ready";
return {
status: status?.status ?? "starting",
errorMessage: status?.errorMessage,
agentExecutionId: status?.agentExecutionId,
isReady: status?.status === "ready",
isReady,
isStarting: status?.status === "starting" || !status,
isError: status?.status === "error",
};
@@ -48,6 +48,7 @@ function createSetters(): { setters: ResumeStateSetter; calls: SetterCalls } {
return { setters, calls };
}
// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability
describe("resumeWithSilentFallback", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -155,4 +156,39 @@ describe("resumeWithSilentFallback", () => {
"Failed to resume session — workspace restore also unavailable",
);
});
it("seeds agentctl ready when restore_workspace fallback succeeds", async () => {
mockRequest
.mockResolvedValueOnce({ success: false, task_id: "t1", state: "FAILED" })
.mockResolvedValueOnce({
success: true,
task_id: "t1",
session_id: "s1",
state: "FAILED",
});
const { setters } = createSetters();
const setAgentctlReady = vi.fn();
setters.setAgentctlReady = setAgentctlReady;
await resumeWithSilentFallback("t1", "s1", null, setters);
expect(setAgentctlReady).toHaveBeenCalledTimes(1);
expect(setAgentctlReady).toHaveBeenCalledWith("s1");
});
it("does not seed agentctl ready when resume succeeds (new execution will emit its own events)", async () => {
mockRequest.mockResolvedValueOnce({
success: true,
task_id: "t1",
session_id: "s1",
state: "STARTING",
});
const { setters } = createSetters();
const setAgentctlReady = vi.fn();
setters.setAgentctlReady = setAgentctlReady;
await resumeWithSilentFallback("t1", "s1", null, setters);
expect(setAgentctlReady).not.toHaveBeenCalled();
});
});
@@ -103,7 +103,7 @@ async function resumeViaLaunch(
setters.setResumptionState("resuming");
const { request } = buildRequest(taskId, sessionId);
const launchResp = await launchSession(request);
applyResumeResponse(
const ok = applyResumeResponse(
{
success: launchResp.success,
state: launchResp.state,
@@ -115,6 +115,16 @@ async function resumeViaLaunch(
session,
setters,
);
// restore_workspace's whole purpose is to bring up agentctl HTTP for an
// otherwise-idle session — when it returns success the workspace+agentctl
// is up by definition. The backend's cached agentctl status snapshot uses
// "workspace stream attached" as its readiness signal, which is wrong on
// WS reconnect (stream detaches but agentctl HTTP keeps running) and the
// existing execution does not re-emit agentctl_ready, so the FileBrowser
// would otherwise stay stuck on "Preparing workspace".
if (ok && request.intent === "restore_workspace" && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
}
/** Attempt resume, silently falling back to restore_workspace on any failure.
@@ -183,6 +193,12 @@ async function tryLaunch(
session,
setters,
);
// See comment in resumeViaLaunch — restore_workspace success implies
// agentctl HTTP is ready, but the existing execution may not re-emit the
// agentctl_ready WS event after WS reconnect.
if (request.intent === "restore_workspace" && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
return true;
} catch (err) {
console.error("[tryLaunch] session launch failed", {
+2 -1
View File
@@ -25,7 +25,8 @@ export function useTaskSessions(taskId: string | null) {
setTaskSessionsLoading(taskId, true);
try {
const response = await listTaskSessions(taskId, { cache: "no-store" });
setTaskSessionsForTask(taskId, response.sessions ?? []);
const sessions = response.sessions ?? [];
setTaskSessionsForTask(taskId, sessions);
} catch {
setTaskSessionsForTask(taskId, []);
} finally {
+1 -2
View File
@@ -104,7 +104,6 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null {
}
if (!structuresMatch) return null;
if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null;
const outgoingSessionPanel = api.panels.find(
@@ -158,7 +157,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
api.layout(safeWidth, safeHeight);
return applyLayoutFixups(api);
} catch {
/* fall through */
/* fall through to default layout build */
}
}
buildDefault(api);
@@ -42,29 +42,26 @@ export const createKanbanSlice: StateCreator<
}
draft.workflows.items = reordered;
}),
setActiveTask: (taskId) => {
setActiveTask: (taskId) =>
set((draft) => {
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = null;
// New task → drop any pin; the pin only applies within a single task.
draft.tasks.pinnedSessionId = null;
});
},
setActiveSession: (taskId, sessionId) => {
}),
setActiveSession: (taskId, sessionId) =>
set((draft) => {
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = sessionId;
// User-initiated selection: pin so WS auto-replace handoff respects it.
draft.tasks.pinnedSessionId = sessionId;
});
},
setActiveSessionAuto: (taskId, sessionId) => {
}),
setActiveSessionAuto: (taskId, sessionId) =>
set((draft) => {
draft.tasks.activeTaskId = taskId;
draft.tasks.activeSessionId = sessionId;
// Auto-driven (WS) selection: don't touch pinnedSessionId.
});
},
}),
clearActiveSession: () =>
set((draft) => {
draft.tasks.activeSessionId = null;
@@ -58,6 +58,7 @@ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSes
worktree_branch: incoming.worktree_branch ?? existing.worktree_branch,
repository_id: incoming.repository_id ?? existing.repository_id,
base_branch: incoming.base_branch ?? existing.base_branch,
task_environment_id: incoming.task_environment_id ?? existing.task_environment_id,
};
}
@@ -288,6 +289,79 @@ function nextPair(
return [current[1], revisionId];
}
function buildTaskSessionActions(set: ImmerSet) {
return {
setTaskSession: (session: Parameters<SessionSlice["setTaskSession"]>[0]) =>
set((draft) => {
const existingSession = draft.taskSessions.items[session.id];
const mergedSession = existingSession
? mergeTaskSession(existingSession, session)
: session;
draft.taskSessions.items[session.id] = mergedSession;
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[session.task_id];
if (sessionsByTask) {
const sessionIndex = sessionsByTask.findIndex((s) => s.id === session.id);
if (sessionIndex >= 0) sessionsByTask[sessionIndex] = mergedSession;
}
syncEnvironmentMapping(draft, session.id, mergedSession.task_environment_id);
}),
removeTaskSession: (taskId: string, sessionId: string) =>
set((draft) => {
delete draft.taskSessions.items[sessionId];
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[taskId];
if (sessionsByTask) {
draft.taskSessionsByTask.itemsByTaskId[taskId] = sessionsByTask.filter(
(s) => s.id !== sessionId,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (draft as any).environmentIdBySessionId[sessionId];
}),
setTaskSessionsForTask: (
taskId: string,
sessions: Parameters<SessionSlice["setTaskSessionsForTask"]>[1],
) =>
set((draft) => {
const merged = sessions.map((session) => {
const existing = draft.taskSessions.items[session.id];
return existing ? mergeTaskSession(existing, session) : session;
});
draft.taskSessionsByTask.itemsByTaskId[taskId] = merged;
draft.taskSessionsByTask.loadingByTaskId[taskId] = false;
draft.taskSessionsByTask.loadedByTaskId[taskId] = true;
for (const session of merged) {
draft.taskSessions.items[session.id] = session;
syncEnvironmentMapping(draft, session.id, session.task_environment_id);
}
}),
// Upsert a session from a WS event without flipping the per-task `loadedByTaskId`
// flag — partial event-driven records must not gate the API hydration that
// fills in fields like agent_profile_id / repository_id / worktree_path.
upsertTaskSessionFromEvent: (
taskId: string,
session: Parameters<SessionSlice["upsertTaskSessionFromEvent"]>[1],
) =>
set((draft) => {
const existing = draft.taskSessions.items[session.id];
const merged = existing ? mergeTaskSession(existing, session) : session;
draft.taskSessions.items[session.id] = merged;
const list = draft.taskSessionsByTask.itemsByTaskId[taskId];
if (list) {
const idx = list.findIndex((s) => s.id === session.id);
if (idx >= 0) list[idx] = merged;
else list.push(merged);
} else {
draft.taskSessionsByTask.itemsByTaskId[taskId] = [merged];
}
syncEnvironmentMapping(draft, session.id, merged.task_environment_id);
}),
setTaskSessionsLoading: (taskId: string, loading: boolean) =>
set((draft) => {
draft.taskSessionsByTask.loadingByTaskId[taskId] = loading;
}),
};
}
export const createSessionSlice: StateCreator<
SessionSlice,
[["zustand/immer", never]],
@@ -313,44 +387,7 @@ export const createSessionSlice: StateCreator<
set((draft) => {
draft.turns.activeBySession[sessionId] = turnId;
}),
setTaskSession: (session) =>
set((draft) => {
const existingSession = draft.taskSessions.items[session.id];
const mergedSession = existingSession ? mergeTaskSession(existingSession, session) : session;
draft.taskSessions.items[session.id] = mergedSession;
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[session.task_id];
if (sessionsByTask) {
const sessionIndex = sessionsByTask.findIndex((s) => s.id === session.id);
if (sessionIndex >= 0) sessionsByTask[sessionIndex] = mergedSession;
}
syncEnvironmentMapping(draft, session.id, mergedSession.task_environment_id);
}),
removeTaskSession: (taskId, sessionId) =>
set((draft) => {
delete draft.taskSessions.items[sessionId];
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[taskId];
if (sessionsByTask) {
draft.taskSessionsByTask.itemsByTaskId[taskId] = sessionsByTask.filter(
(s) => s.id !== sessionId,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (draft as any).environmentIdBySessionId[sessionId];
}),
setTaskSessionsForTask: (taskId, sessions) =>
set((draft) => {
draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions;
draft.taskSessionsByTask.loadingByTaskId[taskId] = false;
draft.taskSessionsByTask.loadedByTaskId[taskId] = true;
for (const session of sessions) {
draft.taskSessions.items[session.id] = session;
syncEnvironmentMapping(draft, session.id, session.task_environment_id);
}
}),
setTaskSessionsLoading: (taskId, loading) =>
set((draft) => {
draft.taskSessionsByTask.loadingByTaskId[taskId] = loading;
}),
...buildTaskSessionActions(set),
setSessionAgentctlStatus: (sessionId, status) =>
set((draft) => {
draft.sessionAgentctl.itemsBySessionId[sessionId] = status;
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createSessionSlice } from "./session-slice";
import { createSessionRuntimeSlice } from "../session-runtime/session-runtime-slice";
import type { SessionSlice } from "./types";
import type { SessionRuntimeSlice } from "../session-runtime/types";
import type { TaskSession } from "@/lib/types/http";
type CombinedSlice = SessionSlice & SessionRuntimeSlice;
function makeStore() {
return create<CombinedSlice>()(
immer((set) => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(createSessionSlice as any)(set),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(createSessionRuntimeSlice as any)(set),
})),
);
}
const TASK_ID = "task-1";
const SESSION_ID = "session-1";
const TS = "2026-04-20T00:00:00Z";
function makeSession(overrides: Partial<TaskSession> = {}): TaskSession {
return {
id: SESSION_ID,
task_id: TASK_ID,
state: "RUNNING",
started_at: TS,
updated_at: TS,
...overrides,
};
}
describe("upsertTaskSessionFromEvent", () => {
it("does not flip loadedByTaskId so API hydration can still run", () => {
const store = makeStore();
store.getState().upsertTaskSessionFromEvent(TASK_ID, makeSession());
expect(store.getState().taskSessionsByTask.loadedByTaskId[TASK_ID]).toBeFalsy();
});
it("merges fields on a second call rather than replacing the row", () => {
const store = makeStore();
store
.getState()
.upsertTaskSessionFromEvent(
TASK_ID,
makeSession({ agent_profile_id: "profile-1", repository_id: "repo-1" }),
);
// Second event omits fields that were set by the first
store.getState().upsertTaskSessionFromEvent(TASK_ID, makeSession({ state: "COMPLETED" }));
const session = store.getState().taskSessions.items[SESSION_ID];
expect(session.state).toBe("COMPLETED");
expect(session.agent_profile_id).toBe("profile-1");
expect(session.repository_id).toBe("repo-1");
});
it("seeds environmentIdBySessionId when task_environment_id is present", () => {
const store = makeStore();
store
.getState()
.upsertTaskSessionFromEvent(TASK_ID, makeSession({ task_environment_id: "env-1" }));
expect(store.getState().environmentIdBySessionId[SESSION_ID]).toBe("env-1");
});
it("does not seed environmentIdBySessionId when task_environment_id is absent", () => {
const store = makeStore();
store.getState().upsertTaskSessionFromEvent(TASK_ID, makeSession());
expect(store.getState().environmentIdBySessionId[SESSION_ID]).toBeUndefined();
});
it("appends to itemsByTaskId when the list already exists", () => {
const store = makeStore();
const other = makeSession({ id: "session-other" });
store.getState().upsertTaskSessionFromEvent(TASK_ID, other);
store.getState().upsertTaskSessionFromEvent(TASK_ID, makeSession());
const list = store.getState().taskSessionsByTask.itemsByTaskId[TASK_ID];
expect(list.map((s) => s.id)).toEqual(["session-other", SESSION_ID]);
});
});
describe("setTaskSessionsForTask preserves WS-seeded fields", () => {
it("merges incoming sessions with existing rows so task_environment_id is not clobbered", () => {
const store = makeStore();
// WS event arrives first and seeds task_environment_id + agent_profile_id
store
.getState()
.upsertTaskSessionFromEvent(
TASK_ID,
makeSession({ task_environment_id: "env-1", agent_profile_id: "profile-1" }),
);
// API hydration arrives next without task_environment_id (race window)
store.getState().setTaskSessionsForTask(TASK_ID, [makeSession({ repository_id: "repo-1" })]);
const session = store.getState().taskSessions.items[SESSION_ID];
expect(session.task_environment_id).toBe("env-1");
expect(session.agent_profile_id).toBe("profile-1");
expect(session.repository_id).toBe("repo-1");
expect(store.getState().environmentIdBySessionId[SESSION_ID]).toBe("env-1");
});
it("flips loadedByTaskId to true (unlike upsertTaskSessionFromEvent)", () => {
const store = makeStore();
store.getState().setTaskSessionsForTask(TASK_ID, [makeSession()]);
expect(store.getState().taskSessionsByTask.loadedByTaskId[TASK_ID]).toBe(true);
});
});
@@ -143,6 +143,7 @@ export type SessionSliceActions = {
setTaskSession: (session: TaskSession) => void;
removeTaskSession: (taskId: string, sessionId: string) => void;
setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void;
upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void;
setTaskSessionsLoading: (taskId: string, loading: boolean) => void;
setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void;
setWorktree: (worktree: Worktree) => void;
+1
View File
@@ -397,6 +397,7 @@ export type AppState = {
setTaskSession: (session: TaskSession) => void;
removeTaskSession: (taskId: string, sessionId: string) => void;
setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void;
upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void;
setTaskSessionsLoading: (taskId: string, loading: boolean) => void;
setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void;
setWorktree: (worktree: Worktree) => void;
+1
View File
@@ -252,6 +252,7 @@ export type TaskSessionWaitingForInputPayload = {
export type TaskSessionAgentctlPayload = {
task_id: string;
session_id: string;
task_environment_id?: string;
agent_execution_id?: string;
error_message?: string;
worktree_id?: string;
@@ -16,6 +16,7 @@ function makeStore(overrides: Record<string, unknown> = {}) {
taskSessionsByTask: { itemsByTaskId: {} },
setTaskSession: vi.fn(),
setTaskSessionsForTask: vi.fn(),
upsertTaskSessionFromEvent: vi.fn(),
setActiveSession: vi.fn(),
setActiveSessionAuto: vi.fn(),
setSessionFailureNotification: vi.fn(),
@@ -453,3 +454,191 @@ describe("session.state_changed → respects user-pinned session", () => {
expect(store.getState().setActiveSession).not.toHaveBeenCalled();
});
});
// eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability
describe("session.state_changed → agentctl ready fallback", () => {
const TS = "2026-05-04T00:00:00Z";
beforeEach(() => {
vi.clearAllMocks();
});
it("promotes agentctl status to 'ready' when session enters RUNNING and ready event was missed", () => {
const setSessionAgentctlStatus = vi.fn();
const store = makeStore({
taskSessions: {
items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } },
},
sessionAgentctl: { itemsBySessionId: { "s-1": { status: "starting" } } },
setSessionAgentctlStatus,
});
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
handler({
id: "m",
type: "notification",
action: STATE_CHANGED_EVENT,
timestamp: TS,
payload: { task_id: "t-1", session_id: "s-1", new_state: "RUNNING" },
});
expect(setSessionAgentctlStatus).toHaveBeenCalledWith(
"s-1",
expect.objectContaining({ status: "ready" }),
);
});
it("promotes agentctl status to 'ready' on WAITING_FOR_INPUT even when no prior entry exists", () => {
const setSessionAgentctlStatus = vi.fn();
const store = makeStore({
taskSessions: {
items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } },
},
sessionAgentctl: { itemsBySessionId: {} },
setSessionAgentctlStatus,
});
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
handler({
id: "m",
type: "notification",
action: STATE_CHANGED_EVENT,
timestamp: TS,
payload: { task_id: "t-1", session_id: "s-1", new_state: "WAITING_FOR_INPUT" },
});
expect(setSessionAgentctlStatus).toHaveBeenCalledWith(
"s-1",
expect.objectContaining({ status: "ready" }),
);
});
it("does not re-set 'ready' when the session is already ready", () => {
const setSessionAgentctlStatus = vi.fn();
const store = makeStore({
taskSessions: {
items: { "s-1": { id: "s-1", task_id: "t-1", state: "RUNNING" } },
},
sessionAgentctl: { itemsBySessionId: { "s-1": { status: "ready" } } },
setSessionAgentctlStatus,
});
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
handler({
id: "m",
type: "notification",
action: STATE_CHANGED_EVENT,
timestamp: TS,
payload: { task_id: "t-1", session_id: "s-1", new_state: "WAITING_FOR_INPUT" },
});
expect(setSessionAgentctlStatus).not.toHaveBeenCalled();
});
it("seeds env mapping from agentctl_starting payload via upsertTaskSessionFromEvent", () => {
const upsertTaskSessionFromEvent = vi.fn();
const store = makeStore({
taskSessions: {
items: { "s-1": { id: "s-1", task_id: "t-1", state: "CREATED" } },
},
sessionAgentctl: { itemsBySessionId: {} },
setSessionAgentctlStatus: vi.fn(),
upsertTaskSessionFromEvent,
});
const handler = registerTaskSessionHandlers(store)["session.agentctl_starting"]!;
handler({
id: "m",
type: "notification",
action: "session.agentctl_starting",
timestamp: TS,
payload: {
task_id: "t-1",
session_id: "s-1",
agent_execution_id: "ae-1",
task_environment_id: "env-1",
},
});
expect(upsertTaskSessionFromEvent).toHaveBeenCalledWith(
"t-1",
expect.objectContaining({ id: "s-1", task_environment_id: "env-1" }),
);
});
it("seeds env mapping from agentctl_ready payload", () => {
const upsertTaskSessionFromEvent = vi.fn();
const store = makeStore({
taskSessions: { items: {} },
sessionAgentctl: { itemsBySessionId: {} },
setSessionAgentctlStatus: vi.fn(),
upsertTaskSessionFromEvent,
setWorktree: vi.fn(),
sessionWorktreesBySessionId: { itemsBySessionId: {} },
setSessionWorktrees: vi.fn(),
});
const handler = registerTaskSessionHandlers(store)["session.agentctl_ready"]!;
handler({
id: "m",
type: "notification",
action: "session.agentctl_ready",
timestamp: TS,
payload: {
task_id: "t-1",
session_id: "s-1",
agent_execution_id: "ae-1",
task_environment_id: "env-1",
},
});
expect(upsertTaskSessionFromEvent).toHaveBeenCalledWith(
"t-1",
expect.objectContaining({ id: "s-1", task_environment_id: "env-1" }),
);
});
it("does not call upsertTaskSessionFromEvent when agentctl payload omits task_environment_id", () => {
const upsertTaskSessionFromEvent = vi.fn();
const store = makeStore({
taskSessions: { items: {} },
sessionAgentctl: { itemsBySessionId: {} },
setSessionAgentctlStatus: vi.fn(),
upsertTaskSessionFromEvent,
});
const handler = registerTaskSessionHandlers(store)["session.agentctl_starting"]!;
handler({
id: "m",
type: "notification",
action: "session.agentctl_starting",
timestamp: TS,
payload: { task_id: "t-1", session_id: "s-1", agent_execution_id: "ae-1" },
});
expect(upsertTaskSessionFromEvent).not.toHaveBeenCalled();
});
it("does not promote on non-live states (STARTING, COMPLETED, FAILED)", () => {
const setSessionAgentctlStatus = vi.fn();
const store = makeStore({
taskSessions: {
items: { "s-1": { id: "s-1", task_id: "t-1", state: "CREATED" } },
},
sessionAgentctl: { itemsBySessionId: {} },
setSessionAgentctlStatus,
});
const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
for (const newState of ["STARTING", "COMPLETED", "FAILED", "CANCELLED"]) {
handler({
id: "m",
type: "notification",
action: STATE_CHANGED_EVENT,
timestamp: TS,
payload: { task_id: "t-1", session_id: "s-1", new_state: newState },
});
}
expect(setSessionAgentctlStatus).not.toHaveBeenCalled();
});
});
+69 -56
View File
@@ -10,10 +10,35 @@ const TERMINAL_SESSION_STATES: ReadonlySet<TaskSessionState> = new Set([
"FAILED",
]);
// States that imply agentctl is up and processing (or has fully processed) input.
// If we observe a session in one of these, agentctl must be ready even if we
// missed the agentctl_ready WS event (e.g. it fired before our subscription).
const AGENT_LIVE_STATES: ReadonlySet<TaskSessionState> = new Set(["RUNNING", "WAITING_FOR_INPUT"]);
export function isTerminalSessionState(state: TaskSessionState | undefined): boolean {
return !!state && TERMINAL_SESSION_STATES.has(state);
}
/** Promote agentctl status to "ready" when the session enters a live state.
* Acts as a fallback for missed/late agentctl_ready WS events — the backend
* cannot reach RUNNING/WAITING_FOR_INPUT without a live agentctl. Never
* downgrades an existing "ready" entry. */
function maybePromoteAgentctlReady(
store: StoreApi<AppState>,
sessionId: string,
newState: TaskSessionState | undefined,
timestamp: string | undefined,
): void {
if (!newState || !AGENT_LIVE_STATES.has(newState)) return;
const current = store.getState().sessionAgentctl?.itemsBySessionId?.[sessionId];
if (current?.status === "ready") return;
store.getState().setSessionAgentctlStatus(sessionId, {
status: "ready",
agentExecutionId: current?.agentExecutionId,
updatedAt: timestamp,
});
}
/**
* When the backend creates a new session for the active task (e.g., due to a
* workflow step transition with a different agent profile), the chat UI should
@@ -73,7 +98,10 @@ function buildSessionUpdate(payload: any): Record<string, unknown> {
return update;
}
/** Upsert the session in the per-task sessions list. */
/** Upsert the session in the per-task sessions list from a WS event.
* Uses `upsertTaskSessionFromEvent` so the per-task list is not marked as
* fully loaded — partial event payloads must not gate the API hydration that
* fills in fields like agent_profile_id / repository_id / worktree_path. */
function upsertTaskSessionList(
store: StoreApi<AppState>,
taskId: string,
@@ -82,49 +110,19 @@ function upsertTaskSessionList(
payload: any,
sessionUpdate: Record<string, unknown>,
): void {
const sessionsByTask = store.getState().taskSessionsByTask.itemsByTaskId[taskId];
const newState = payload.new_state as TaskSessionState | undefined;
const existing = store.getState().taskSessions.items[sessionId];
if (!existing && !newState) return;
if (!sessionsByTask) {
// Initialize per-task sessions from WS event so the sidebar can
// display up-to-date status even for tasks not yet clicked.
if (newState) {
store.getState().setTaskSessionsForTask(taskId, [
{
id: sessionId,
task_id: taskId,
state: newState,
started_at: "",
updated_at: "",
...(payload.agent_profile_id ? { agent_profile_id: payload.agent_profile_id } : {}),
...sessionUpdate,
},
]);
}
return;
}
const hasSession = sessionsByTask.some((s) => s.id === sessionId);
if (!hasSession && newState) {
store.getState().setTaskSessionsForTask(taskId, [
...sessionsByTask,
{
id: sessionId,
task_id: taskId,
state: newState,
started_at: "",
updated_at: "",
...(payload.agent_profile_id ? { agent_profile_id: payload.agent_profile_id } : {}),
...sessionUpdate,
},
]);
} else if (hasSession) {
store.getState().setTaskSessionsForTask(
taskId,
sessionsByTask.map((s) => (s.id === sessionId ? { ...s, ...sessionUpdate } : s)),
);
}
store.getState().upsertTaskSessionFromEvent(taskId, {
id: sessionId,
task_id: taskId,
state: (newState ?? existing?.state) as TaskSessionState,
started_at: existing?.started_at ?? "",
updated_at: existing?.updated_at ?? "",
...(payload.agent_profile_id ? { agent_profile_id: payload.agent_profile_id } : {}),
...sessionUpdate,
});
}
/** Extract context window data from payload metadata and store it. */
@@ -197,6 +195,32 @@ function maybeAdoptSessionOnTransition(
}
}
/** Seed the session→environment mapping from an agentctl event payload.
* Brand-new CREATED sessions never receive a `state_changed` carrying
* `task_environment_id` (no transition fires), so the env mapping would
* otherwise stay empty and env-routed shell terminals stall on
* "Connecting terminal...". Routes through `upsertTaskSessionFromEvent`
* which calls `syncEnvironmentMapping` and merges with any existing row. */
function syncEnvFromAgentctlPayload(
store: StoreApi<AppState>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): void {
const taskId = payload?.task_id;
const sessionId = payload?.session_id;
const envId = payload?.task_environment_id;
if (!taskId || !sessionId || !envId) return;
const existing = store.getState().taskSessions.items[sessionId];
store.getState().upsertTaskSessionFromEvent(taskId, {
id: sessionId,
task_id: taskId,
state: existing?.state ?? "CREATED",
started_at: existing?.started_at ?? "",
updated_at: existing?.updated_at ?? "",
task_environment_id: envId,
});
}
/** Handle the agentctl_ready event: update session worktree info. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleAgentctlReady(store: StoreApi<AppState>, payload: any): void {
@@ -285,22 +309,9 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
const sessionUpdate = buildSessionUpdate(payload);
const existingSession = store.getState().taskSessions.items[sessionId];
if (existingSession) {
store.getState().setTaskSession({ ...existingSession, ...sessionUpdate });
} else if (newState) {
store.getState().setTaskSession({
id: sessionId,
task_id: taskId,
state: newState as TaskSessionState,
started_at: "",
updated_at: "",
...(payload.agent_profile_id ? { agent_profile_id: payload.agent_profile_id } : {}),
...sessionUpdate,
});
}
upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate);
extractContextWindow(store, sessionId, payload);
maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp);
maybeAdoptSessionOnTransition(store, taskId, sessionId, newState, !!existingSession);
@@ -320,6 +331,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
agentExecutionId: payload.agent_execution_id,
updatedAt: message.timestamp,
});
syncEnvFromAgentctlPayload(store, payload);
},
"session.agentctl_ready": (message) => {
const payload = message.payload;
@@ -329,6 +341,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
agentExecutionId: payload.agent_execution_id,
updatedAt: message.timestamp,
});
syncEnvFromAgentctlPayload(store, payload);
handleAgentctlReady(store, payload);
},
"session.agentctl_error": (message) => {