Files
kandev/apps/web/lib/ws/handlers/agent-session.ts
T
2026-08-01 09:45:06 +01:00

660 lines
27 KiB
TypeScript

import type { StoreApi } from "zustand";
import { createDebugLogger } from "@/lib/debug/log";
import type { AppState } from "@/lib/state/store";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import {
sessionId as toSessionId,
taskId as toTaskId,
type SessionId,
type TaskId,
type TaskSessionState,
} from "@/lib/types/http";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync";
import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window";
const debug = createDebugLogger("session:state");
const TERMINAL_SESSION_STATES: ReadonlySet<TaskSessionState> = new Set([
"COMPLETED",
"CANCELLED",
"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);
}
function findSessionForTask(state: AppState, taskId: string, sessionId: string) {
const byTask = state.taskSessionsByTask;
const sessionsForTask = byTask?.itemsByTaskId?.[taskId];
if (byTask?.loadedByTaskId?.[taskId]) {
return sessionsForTask?.find((session) => session.id === sessionId) ?? null;
}
// Some task-update call sites use partial stores before taskSessions hydrates.
const byId = state.taskSessions?.items?.[sessionId];
if (byId) return byId.task_id === taskId ? byId : null;
return sessionsForTask?.find((session) => session.id === sessionId) ?? null;
}
function isTaskSessionListHydrating(state: AppState, taskId: string): boolean {
const byTask = state.taskSessionsByTask;
if (!byTask) return true;
if (byTask.loadingByTaskId?.[taskId]) return true;
return !byTask.loadedByTaskId?.[taskId];
}
/**
* Manual session selection pins a task-scoped session. Background WS events
* may only override that pin once the pinned session is known terminal, or
* when the terminal event is for the pinned session itself.
*/
export function shouldPreservePinnedSessionForTask(
state: AppState,
taskId: string,
incoming?: { sessionId: string; newState: TaskSessionState | undefined },
): boolean {
const pinnedSessionId = state.tasks.pinnedSessionId;
if (!pinnedSessionId || state.tasks.activeTaskId !== taskId) return false;
if (
incoming?.sessionId === pinnedSessionId &&
incoming.newState &&
isTerminalSessionState(incoming.newState)
) {
return false;
}
const pinnedSession = findSessionForTask(state, taskId, pinnedSessionId);
if (!pinnedSession) {
// Preserve missing rows only while the per-task list is still hydrating.
// Once loaded, absence means the pinned session was deleted or went stale.
return isTaskSessionListHydrating(state, taskId);
}
return !isTerminalSessionState(pinnedSession.state);
}
export function clearPinnedSessionIfOverridden(store: StoreApi<AppState>, sessionId: string): void {
const pinnedSessionId = store.getState().tasks.pinnedSessionId;
if (!pinnedSessionId || pinnedSessionId === sessionId) return;
store.setState((state) => ({
...state,
tasks: { ...state.tasks, pinnedSessionId: null },
}));
}
/** 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
* follow the switch. Returns true when the caller should adopt the new session
* as the task's active session.
*
* Adopts only when the current active session is missing, cross-task, parked
* IDLE, or already terminal — not while a live session for the same task is
* still running. A parked IDLE session has no live process, so a newly started
* workflow session should replace it just like a terminal session.
*/
export function shouldAdoptNewSession(
state: AppState,
taskId: string,
newState: TaskSessionState | undefined,
): boolean {
if (!newState || isTerminalSessionState(newState)) return false;
if (state.tasks.activeTaskId !== taskId) return false;
const activeSessionId = state.tasks.activeSessionId;
if (activeSessionId) {
const activeSession = state.taskSessions.items[activeSessionId];
if (
activeSession?.task_id === taskId &&
activeSession.state !== "IDLE" &&
!isTerminalSessionState(activeSession.state)
) {
return false;
}
}
return true;
}
/**
* Pick the newest non-terminal session for a task. Used when the currently
* active session just reached a terminal state — we want to hand focus to the
* session that replaced it (typically created by a workflow step transition).
*/
export function pickReplacementSessionId(state: AppState, taskId: string): string | null {
const sessions = state.taskSessionsByTask.itemsByTaskId[taskId];
if (!sessions) return null;
for (let i = sessions.length - 1; i >= 0; i -= 1) {
const candidate = sessions[i];
if (!isTerminalSessionState(candidate.state)) return candidate.id;
}
return null;
}
/**
* The authoritative primary session for a task, read from the kanban task row
* (kept in sync by task.updated's primary_session_id). Fallback replacement
* target when a retired session has no non-terminal successor in the per-task
* session list yet: a workflow step switch promotes the new primary via
* task.updated *before* the old session's terminal state_changed arrives, so
* the new session row can be momentarily absent from taskSessionsByTask. Using
* the promoted primary lets focus follow the switch regardless of that race.
*/
export function pickPrimarySessionReplacement(
state: AppState,
taskId: string,
retiredSessionId: string,
): string | null {
const task =
state.kanban.tasks.find((t) => t.id === taskId) ??
Object.values(state.kanbanMulti.snapshots)
.flatMap((snapshot) => snapshot.tasks)
.find((t) => t.id === taskId);
const primary = task?.primarySessionId ?? null;
return primary && primary !== retiredSessionId ? primary : null;
}
/** Ignore subscribe snapshots that were read before a newer state landed. */
export function isStaleSessionStateEvent(
existing: { updated_at?: string } | null | undefined,
payloadUpdatedAt: string | undefined,
): boolean {
if (!payloadUpdatedAt || !existing?.updated_at) return false;
const payloadTime = Date.parse(payloadUpdatedAt);
const existingTime = Date.parse(existing.updated_at);
if (Number.isNaN(payloadTime) || Number.isNaN(existingTime)) return false;
// Strict less-than: equal timestamps are treated as not-stale so identical
// events upsert idempotently rather than being silently dropped.
return payloadTime < existingTime;
}
/** Build a session update object from the state_changed payload. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildSessionUpdate(payload: any): Record<string, unknown> {
const update: Record<string, unknown> = {};
if (payload.new_state) update.state = payload.new_state;
if (payload.agent_profile_id) update.agent_profile_id = payload.agent_profile_id;
if (payload.review_status !== undefined) update.review_status = payload.review_status;
if (payload.error_message !== undefined) update.error_message = payload.error_message;
if (payload.agent_profile_snapshot)
update.agent_profile_snapshot = payload.agent_profile_snapshot;
if (payload.is_passthrough !== undefined) update.is_passthrough = payload.is_passthrough;
if (payload.session_metadata !== undefined) update.metadata = payload.session_metadata;
// Apply only when the key is present: rename events always carry `name`
// (including "" for a cleared label); other session events omit it.
if (payload.name !== undefined) update.name = payload.name;
if (payload.task_environment_id) update.task_environment_id = payload.task_environment_id;
if (payload.updated_at) update.updated_at = payload.updated_at;
// Carry the authoritative activity value across coarse transitions. A new
// foreground turn resets it to generating; settled detached work may remain
// background (ADR-0049).
if (payload.foreground_activity !== undefined)
update.foreground_activity = payload.foreground_activity;
if (payload.active_subagent_count !== undefined)
update.active_subagent_count = payload.active_subagent_count;
return update;
}
/** Upsert the session in the per-task sessions list from a WS event.
* Uses `upsertTaskSessionFromEvent` so a new partial row invalidates any
* previously loaded list, allowing API hydration to fill fields like
* agent_profile_id / repository_id / worktree_path. */
function upsertTaskSessionList(
store: StoreApi<AppState>,
taskId: TaskId,
sessionId: SessionId,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
sessionUpdate: Record<string, unknown>,
): void {
const newState = payload.new_state as TaskSessionState | undefined;
const existing = store.getState().taskSessions.items[sessionId];
if (!existing && !newState) return;
store.getState().upsertTaskSessionFromEvent(taskId, {
id: sessionId,
task_id: taskId,
state: (newState ?? existing?.state) as TaskSessionState,
started_at: existing?.started_at ?? "",
updated_at: (sessionUpdate.updated_at as string | undefined) ?? existing?.updated_at ?? "",
...(payload.agent_profile_id ? { agent_profile_id: payload.agent_profile_id } : {}),
...sessionUpdate,
});
}
// Fan out the office refetch trigger only when the session's state
// actually changed. The WS layer fires `session.state_changed` for
// several adjacent reasons (agentctl status, context window, model
// updates) where `new_state` is undefined or unchanged; without this
// gate every one of those storms the dashboard-card re-render path.
function maybeFanOutOfficeRefetch(
store: StoreApi<AppState>,
newState: TaskSessionState | undefined,
prevState: TaskSessionState | undefined,
): void {
if (!newState || newState === prevState) return;
const setOfficeTrigger = store.getState().setOfficeRefetchTrigger;
if (!setOfficeTrigger) return;
setOfficeTrigger("dashboard");
setOfficeTrigger("agents");
}
/** Extract context-window data or an explicit cache invalidation from a session event. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractContextWindow(store: StoreApi<AppState>, sessionId: string, payload: any): void {
const metadataSources = [payload.metadata, payload.session_metadata];
const metadata = metadataSources.find(
(candidate) =>
candidate &&
typeof candidate === "object" &&
Object.prototype.hasOwnProperty.call(candidate, "context_window"),
) as Record<string, unknown> | undefined;
if (!metadata) return;
const contextWindow = metadata.context_window;
const entry = parseContextWindowEntry(contextWindow, new Date().toISOString());
if (entry) store.getState().setContextWindow(sessionId, entry);
else store.getState().clearContextWindow(sessionId);
}
/** Copy agentctl "ready" status from one session to another (same-task switch). */
function inheritAgentctlStatus(state: AppState, fromSessionId: string, toSessionId: string): void {
const oldAgentctl = state.sessionAgentctl?.itemsBySessionId?.[fromSessionId];
if (oldAgentctl?.status === "ready") {
state.setSessionAgentctlStatus(toSessionId, oldAgentctl);
}
}
/**
* After a `session.state_changed` event, decide whether the chat UI should
* follow a workflow-driven session switch. Covers both event orderings:
* 1. New non-terminal session appears for the active task before the old
* one is torn down — adopt immediately.
* 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 (
state.tasks.pinnedSessionId !== sessionId &&
shouldPreservePinnedSessionForTask(state, taskId, { sessionId, newState })
) {
return;
}
if (!wasKnownToStore && shouldAdoptNewSession(state, taskId, newState)) {
const oldSessionId = state.tasks.activeSessionId;
if (oldSessionId) inheritAgentctlStatus(state, oldSessionId, sessionId);
clearPinnedSessionIfOverridden(store, sessionId);
state.setActiveSessionAuto(taskId, sessionId);
return;
}
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;
// Prefer a non-terminal session already in the per-task list; fall back to
// the task's promoted primary when the switch's new session hasn't landed
// there yet (task.updated beats the old session's terminal state_changed).
const replacement =
pickReplacementSessionId(state, taskId) ??
pickPrimarySessionReplacement(state, taskId, sessionId);
if (replacement && replacement !== sessionId) {
inheritAgentctlStatus(state, sessionId, replacement);
clearPinnedSessionIfOverridden(store, replacement);
state.setActiveSessionAuto(taskId, replacement);
}
}
}
/** 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 rawTaskId = payload?.task_id;
const rawSessionId = payload?.session_id;
const envId = payload?.task_environment_id;
if (!rawTaskId || !rawSessionId || !envId) return;
const taskId = toTaskId(rawTaskId);
const sessionId = toSessionId(rawSessionId);
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,
worktree_id: payload.worktree_id,
worktree_path: payload.worktree_path,
worktree_branch: payload.worktree_branch,
});
}
/** Builds the partial-session patch applied for an agentctl_ready event.
* On sibling materialize we repoint worktree_path to the task root and keep
* the primary's id/branch; the initial ready event sets id/path/branch
* straight from the payload. */
function buildAgentctlReadySessionUpdate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
isSibling: boolean,
): Record<string, unknown> {
const update: Record<string, unknown> = {};
if (isSibling) {
if (payload.task_workspace_path) update.worktree_path = payload.task_workspace_path;
return update;
}
if (payload.worktree_id) update.worktree_id = payload.worktree_id;
if (payload.worktree_path) update.worktree_path = payload.worktree_path;
if (payload.worktree_branch) update.worktree_branch = payload.worktree_branch;
return update;
}
/** Adds the materialized worktree to the worktrees map + the per-session list. */
function recordAgentctlReadyWorktree(
store: StoreApi<AppState>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
existingSession: { repository_id?: string; worktree_path?: string; worktree_branch?: string },
): void {
if (!payload.worktree_id) return;
store.getState().setWorktree({
id: payload.worktree_id,
sessionId: payload.session_id,
repositoryId: existingSession.repository_id ?? undefined,
path: payload.worktree_path ?? existingSession.worktree_path ?? undefined,
branch: payload.worktree_branch ?? existingSession.worktree_branch ?? undefined,
});
const existing =
store.getState().sessionWorktreesBySessionId.itemsBySessionId[payload.session_id] ?? [];
if (!existing.includes(payload.worktree_id)) {
store.getState().setSessionWorktrees(payload.session_id, [...existing, payload.worktree_id]);
}
}
/** Handle the agentctl_ready event: update session worktree info.
*
* Two shapes share this event:
* 1. Initial session ready — payload describes the session's primary
* worktree; we set worktree_id/path/branch on the session row.
* 2. Sibling materialized (multi-branch add_branch flow) — payload
* describes a NEW worktree being added alongside the primary. The
* primary's worktree_id/branch must NOT be clobbered (they still own
* the chat/agent process); only worktree_path moves to the task root
* so the file browser repoints from "primary worktree" to "task root
* containing both worktree siblings". A commits refetch is bumped so
* the Commits panel re-queries with the new multi-repo subpaths
* (each commit then carries its repo/branch slug for grouping).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleAgentctlReady(store: StoreApi<AppState>, payload: any): void {
const existingSession = store.getState().taskSessions.items[payload.session_id];
if (!existingSession) return;
const isSibling =
!!payload.worktree_id &&
!!existingSession.worktree_id &&
payload.worktree_id !== existingSession.worktree_id;
const sessionUpdate = buildAgentctlReadySessionUpdate(payload, isSibling);
if (Object.keys(sessionUpdate).length > 0) {
store.getState().setTaskSession({ ...existingSession, ...sessionUpdate });
}
recordAgentctlReadyWorktree(store, payload, existingSession);
if (isSibling) {
// Drop the pre-multi-repo git-status snapshot — the backend just
// transitioned this session from single-repo to multi-repo and the legacy
// (empty-repo-name) tracker is gone. Without this the Changes panel keeps
// surfacing the frozen snapshot until the user reloads the tab, masking
// the per-repo updates streaming in for both the primary and the sibling.
store.getState().clearLegacyGitStatusEntry(payload.session_id);
store.getState().bumpSessionCommitsRefetch(payload.session_id);
}
}
interface SessionFailureContext {
taskId: TaskId;
sessionId: SessionId;
newState: TaskSessionState | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any;
previousState: TaskSessionState | undefined;
}
/** Emits a session-failure notification for FAILED transitions, honoring suppress_toast and replay guards. */
function maybeNotifySessionFailure(store: StoreApi<AppState>, ctx: SessionFailureContext): void {
const { taskId, sessionId, newState, payload, previousState } = ctx;
if (newState !== "FAILED") return;
// Only toast on observed transitions (previousState present and not FAILED).
// If previousState is undefined we're learning about this session for the
// first time — that's a snapshot of an already-failed session being replayed
// by the backend (e.g. on page load / WS reconnect), not a fresh failure.
if (
payload.suppress_toast === true ||
previousState === undefined ||
previousState === "FAILED"
) {
return;
}
store.getState().setSessionFailureNotification({
sessionId,
taskId,
message: payload.error_message ? String(payload.error_message) : "Session failed unexpectedly",
});
}
/** Apply a fine-grained busy-substate flip (ADR-0049). Annotates the
* existing session row so the composer gate and status indicator update; does
* nothing until the row exists (state_changed seeds it first). */
function applyForegroundActivity(
store: StoreApi<AppState>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): void {
if (!payload?.task_id || !payload?.session_id) return;
const taskId = toTaskId(payload.task_id);
const sessionId = toSessionId(payload.session_id);
const existing = store.getState().taskSessions.items[sessionId];
if (!existing) return;
// Detached work can outlive the foreground turn, whose coarse state is then
// WAITING_FOR_INPUT. Terminal/parked sessions reject delayed activity frames;
// their execution teardown owns the final clear.
if (existing.state !== "RUNNING" && existing.state !== "WAITING_FOR_INPUT") return;
if (existing.task_id && existing.task_id !== taskId) return;
store.getState().upsertTaskSessionFromEvent(taskId, {
id: sessionId,
task_id: taskId,
state: existing.state,
started_at: existing.started_at ?? "",
updated_at: existing.updated_at ?? "",
foreground_activity: payload.foreground_activity ?? null,
active_subagent_count:
payload.active_subagent_count !== undefined
? payload.active_subagent_count
: (existing.active_subagent_count ?? 0),
});
}
function handleWorkspaceSourcesUpdated(
store: StoreApi<AppState>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any,
): void {
const {
session_id: sessionId,
workspace_path: workspacePath,
adopted_session_ids: adoptedSessionIds,
} = payload;
const existing = store.getState().taskSessions.items[sessionId];
if (existing) store.getState().setTaskSession({ ...existing, worktree_path: workspacePath });
store.getState().reconcileWorkspaceSourcesAdopted(adoptedSessionIds ?? [sessionId]);
store.getState().bumpWorkspaceFilesRefresh(sessionId);
store.getState().clearLegacyGitStatusEntry(sessionId);
store.getState().bumpSessionCommitsRefetch(sessionId);
}
export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandlers {
return {
"message.queue.status_changed": (message) => {
const payload = message.payload;
if (!payload?.session_id) {
console.warn("[Queue] Missing session_id in queue status change event");
return;
}
const sessionId = payload.session_id;
const entries = (payload.entries as QueuedMessage[] | null | undefined) ?? [];
const count = typeof payload.count === "number" ? payload.count : entries.length;
const max = typeof payload.max === "number" ? payload.max : 0;
store.getState().setQueueEntries(sessionId, entries, { count, max });
},
"session.state_changed": (message) => {
const payload = message.payload;
if (!payload?.task_id) return;
const { task_id: rawTaskId, session_id: rawSessionId } = payload;
const newState = payload.new_state as TaskSessionState | undefined;
if (!rawSessionId) return;
const taskId = toTaskId(rawTaskId);
const sessionId = toSessionId(rawSessionId);
const sessionUpdate = buildSessionUpdate(payload);
const existingSession = store.getState().taskSessions.items[sessionId];
if (isStaleSessionStateEvent(existingSession, payload.updated_at)) {
debug("state_changed ignored stale snapshot", {
sessionId,
task_id: taskId,
existingUpdatedAt: existingSession?.updated_at,
payloadUpdatedAt: payload.updated_at,
newState: newState ?? "-",
});
return;
}
debug("state_changed", {
sessionId,
// Logged before upsertTaskSessionList below, so on the first event for a
// session the store has no row yet and the auto-resolver can't map it —
// exactly the oldState="-" anchor line. taskId is already in scope, so
// pass it directly (rendered as task_id=, matching the auto-annotation).
task_id: taskId,
oldState: existingSession?.state ?? "-",
newState: newState ?? "-",
});
upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate);
syncKanbanPrimarySessionState(store, taskId, sessionId, newState);
extractContextWindow(store, sessionId, payload);
maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp);
maybeAdoptSessionOnTransition(
store,
taskId,
sessionId,
newState,
!!existingSession,
existingSession?.state,
);
maybeNotifySessionFailure(store, {
taskId,
sessionId,
newState,
payload,
previousState: existingSession?.state,
});
maybeFanOutOfficeRefetch(store, newState, existingSession?.state);
},
"session.activity_changed": (message) => {
applyForegroundActivity(store, message.payload);
},
"session.agentctl_starting": (message) => {
const payload = message.payload;
if (!payload?.session_id) return;
store.getState().setSessionAgentctlStatus(payload.session_id, {
status: "starting",
agentExecutionId: payload.agent_execution_id,
updatedAt: message.timestamp,
});
syncEnvFromAgentctlPayload(store, payload);
},
"session.agentctl_ready": (message) => {
const payload = message.payload;
if (!payload?.session_id) return;
store.getState().setSessionAgentctlStatus(payload.session_id, {
status: "ready",
agentExecutionId: payload.agent_execution_id,
updatedAt: message.timestamp,
});
syncEnvFromAgentctlPayload(store, payload);
handleAgentctlReady(store, payload);
},
"session.agentctl_error": (message) => {
const payload = message.payload;
if (!payload?.session_id) return;
store.getState().setSessionAgentctlStatus(payload.session_id, {
status: "error",
agentExecutionId: payload.agent_execution_id,
errorMessage: payload.error_message,
updatedAt: message.timestamp,
});
},
"session.workspace_sources.updated": (message) =>
handleWorkspaceSourcesUpdated(store, message.payload),
};
}