fix: stabilize task switch flow and session recovery (#818)
This commit is contained in:
@@ -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", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user