Files
openswarm/frontend/src/shared/state/agentsSlice.ts
T

1547 lines
64 KiB
TypeScript

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
import { normalizeSessionName } from './sessionDisplay';
import { mergeSessionMessages } from './mergeSessionMessages';
const AGENTS_API = `${API_BASE}/agents`;
// Appended (server-side) to the base agent prompt for the very first run, so the welcome agent opens like a sharp teammate: narrow down an open-ended ask before diving in.
const WELCOME_EXPLORATORY_PROMPT =
"This is the user's very first task with you. Be a warm, sharp teammate: if their request " +
"is open-ended or vague, ask 1-2 short clarifying questions to pin down exactly what they " +
"want and care about before you start, then do it. If it's already concrete, just do it. " +
'Keep any questions brief and friendly, never a wall of text.';
export interface AgentMessage {
id: string;
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system' | 'thinking';
content: any;
timestamp: string;
branch_id: string;
parent_id: string | null;
context_paths?: Array<{ path: string; type: string }>;
attached_skills?: Array<{ id: string; name: string }>;
forced_tools?: string[];
images?: Array<{ data: string; media_type: string }>;
hidden?: boolean;
/** Round-tripped optimistic-bubble id; addMessage dedupes the echo against the placeholder. */
client_message_id?: string;
/** Frontend-only optimistic lifecycle; dropped on server-echoed messages. */
optimistic_status?: 'pending' | 'failed';
/** Server-stamped duration/token counts; today only thinking messages set these. */
elapsed_ms?: number;
tokens?: number;
/** Input-side token count for the turn (fresh + cache_creation + cache_read). */
input_tokens?: number;
tool_count?: number;
}
export interface ApprovalRequest {
id: string;
session_id: string;
tool_name: string;
tool_input: Record<string, any>;
created_at: string;
sensitive_pattern?: string | null;
sensitive_label?: string | null;
sensitive_why?: string | null;
}
export interface MessageBranch {
id: string;
parent_branch_id: string | null;
fork_point_message_id: string | null;
created_at: string;
}
// StreamingMessage moved to streamingSlice; re-exported for back-compat.
export type { StreamingMessage } from './streamingSlice';
export interface ToolGroupMeta {
id: string;
name: string;
svg: string;
is_refined: boolean;
}
export interface AgentSession {
id: string;
name: string;
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
/** For workflow Test Agent sessions: drives the test card's footer
* (running -> red Force Stop; complete/error -> green close). */
workflow_test_state?: 'running' | 'complete' | 'error' | null;
provider: string;
model: string;
mode: string;
worktree_path: string | null;
branch_name: string | null;
sdk_session_id: string | null;
system_prompt: string | null;
allowed_tools: string[];
max_turns: number | null;
created_at: string;
closed_at?: string | null;
cost_usd: number;
tokens: { input: number; output: number };
messages: AgentMessage[];
/** Compact dashboard-list metadata; full messages are fetched when a chat opens. */
last_message_preview?: string;
first_user_message?: string;
message_count?: number;
/** WS seq high-water at snapshot time (GET /sessions only); seeds the resume cursor so connect skips replaying what REST just delivered. */
event_seq?: number;
pending_approvals: ApprovalRequest[];
branches: Record<string, MessageBranch>;
active_branch_id: string;
// streamingMessage lives in state.streaming.bySession[id]; see streamingSlice.
target_directory?: string | null;
tool_group_meta: Record<string, ToolGroupMeta>;
dashboard_id?: string;
browser_id?: string | null;
parent_session_id?: string | null;
/** Set when this session IS a workflow run's agent; the run shows in the Workflows monitor, so it gets no standalone canvas card. */
workflow_run_id?: string | null;
/** Set when this session IS a workflow's embedded edit/compose chat; it lives in the Workflows hub, so it gets no standalone card and its browser docks below the hub. */
workflow_edit_id?: string | null;
/** Browser memory signals that drive the subtle "Remembered"/"Learned" card chip. */
memory_recalled?: boolean;
memory_learned?: boolean;
/** Client-only: turn is waiting on the admission gate (agent:queued -> agent:admitted). Transient; self-clears on the next full-session status update. */
queued?: boolean;
thinking_level?: 'off' | 'low' | 'medium' | 'high' | 'auto';
active_mcps?: string[];
ctx_used_pct?: number;
cache_read_pct?: number;
cache_read_tokens?: number;
context_window?: number;
framework_overhead_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
rate_limited?: { retry_after_s: number | null; at: string } | null;
provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null;
context_recovered?: { at: string } | null;
// Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts.
app_deps_changed?: boolean;
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
mcp_suggestions_is_vague?: boolean;
compacted_through_msg_id?: string | null;
/** Frontend-only WS state, decoupled from session.status so reconnects don't fake terminal states. */
connection_state?: 'live' | 'reconnecting';
/** Aux-LLM verb-phrase for the current turn; ThinkingBubble swaps in then back when turn ends. */
turn_label?: { label: string; turn_id: string } | null;
/** Frontend-only: this draft is the first-run welcome (seeded greeting + quick-reply chips).
* Dropped on the server swap in launchAndSendFirstMessage.fulfilled, so it never persists. */
is_welcome_draft?: boolean;
}
export interface AgentConfig {
name?: string;
provider?: string;
model?: string;
mode?: string;
system_prompt?: string;
allowed_tools?: string[];
max_turns?: number;
target_directory?: string;
dashboard_id?: string;
selected_app_output_ids?: string[];
// Onboarding's unattended audit runs read-only over the user's real files (Edit/Bash hard-blocked).
read_only?: boolean;
}
export interface HistorySession {
id: string;
name: string;
status: string;
model: string;
mode: string;
created_at: string;
closed_at: string | null;
cost_usd: number;
dashboard_id?: string;
}
interface HistorySearchState {
results: HistorySession[];
total: number;
hasMore: boolean;
query: string;
loading: boolean;
}
interface AgentsState {
sessions: Record<string, AgentSession>;
history: Record<string, HistorySession>;
activeSessionId: string | null;
expandedSessionIds: string[];
loading: boolean;
historySearch: HistorySearchState;
trackedNotificationIds: string[];
// Draft session id => real backend id; bound components find their session without leaking activeSessionId.
draftLaunchMap: Record<string, string>;
}
const initialState: AgentsState = {
sessions: {},
history: {},
activeSessionId: null,
expandedSessionIds: [],
loading: false,
historySearch: { results: [], total: 0, hasMore: false, query: '', loading: false },
trackedNotificationIds: [],
draftLaunchMap: {},
};
export const fetchSessions = createAsyncThunk(
'agents/fetchSessions',
// dashboardId is REQUIRED: the fulfilled reducer treats the response as authority and strips
// dead sessions, and an unscoped answer must never carry that power (see the reducer).
async ({ dashboardId }: { dashboardId: string }) => {
const params = new URLSearchParams();
params.set('dashboard_id', dashboardId);
const res = await fetch(`${AGENTS_API}/sessions?${params.toString()}`);
// Same rule the layout fetch learned the hard way: a non-2xx body silently parsing to "no
// sessions" is how a healthy board gets wiped. An error must land in .rejected (which strips
// nothing), not masquerade as an empty fulfilled.
if (!res.ok) throw new Error(`sessions fetch failed: ${res.status}`);
const data = await res.json();
return data.sessions as AgentSession[];
},
);
export const launchAgent = createAsyncThunk('agents/launchAgent', async (config: AgentConfig) => {
const res = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const data = await res.json();
return data.session as AgentSession;
});
export interface SendMessagePayload {
sessionId: string;
prompt: string;
mode?: string;
model?: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
hidden?: boolean;
selectedBrowserIds?: string[];
selectedAppIds?: string[];
selectedSettingIds?: string[];
}
function _genOptimisticId(): string {
return `opt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds, selectedAppIds, selectedSettingIds }: SendMessagePayload, { dispatch }) => {
// Mint client id and dispatch optimistic bubble before awaiting the network; id round-trips for echo dedupe.
const clientMessageId = _genOptimisticId();
dispatch(addOptimisticMessage({
sessionId,
clientMessageId,
prompt,
contextPaths,
forcedTools,
attachedSkills: attachedSkills?.map((s) => ({ id: s.id, name: s.name })),
images: images?.map((img) => ({ data: img.data, media_type: img.media_type })),
hidden: hidden ?? false,
}));
try {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }),
});
if (!res.ok) throw new Error(`send failed: ${res.status}`);
} catch (err) {
dispatch(markOptimisticFailed({ sessionId, clientMessageId }));
throw err;
}
return { sessionId, prompt, clientMessageId };
}
);
// Carry-the-task: after the free trial runs dry and the user connects their own model, resend the last thing they asked so it picks up on the new model instead of being lost. Explicit (a tap), never auto-fired on a settings change, and it reuses the session's now-current model server-side.
export const retryLastUserMessage = createAsyncThunk(
'agents/retryLastUserMessage',
async ({ sessionId }: { sessionId: string }, { getState, dispatch }) => {
const s = (getState() as { agents: { sessions: Record<string, AgentSession> } }).agents.sessions[sessionId];
if (!s || !s.messages) return;
const branch = s.active_branch_id || 'main';
const lastUser = [...s.messages]
.filter((m) => (m.branch_id || 'main') === branch && m.role === 'user')
.pop();
if (!lastUser) return;
const content = typeof lastUser.content === 'string' ? lastUser.content : JSON.stringify(lastUser.content);
if (!content.trim()) return;
await dispatch(sendMessage({ sessionId, prompt: content }));
}
);
export const stopAgent = createAsyncThunk(
'agents/stopAgent',
async ({ sessionId, removeWorktree = false }: { sessionId: string; removeWorktree?: boolean }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove_worktree: removeWorktree }),
});
return sessionId;
}
);
export const editMessage = createAsyncThunk(
'agents/editMessage',
async ({ sessionId, messageId, content }: { sessionId: string; messageId: string; content: string }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/edit_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId, content }),
});
return { sessionId, messageId, content };
}
);
export const switchBranch = createAsyncThunk(
'agents/switchBranch',
async ({ sessionId, branchId }: { sessionId: string; branchId: string }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/switch_branch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch_id: branchId }),
});
return { sessionId, branchId };
}
);
export interface LaunchAndSendPayload {
draftId: string;
config: AgentConfig;
prompt: string;
mode: string;
model: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
expand?: boolean;
selectedBrowserIds?: string[];
selectedAppIds?: string[];
selectedSettingIds?: string[];
}
export const fetchSession = createAsyncThunk(
'agents/fetchSession',
async (sessionId: string, { rejectWithValue, getState }) => {
// Snapshot whether a stream is live for this session BEFORE the await, so the merge reducer can treat a streaming session as "live" and keep the just-sent user bubble (streaming lives in streamingSlice and never flips session.status to running, so the status-only liveness check missed mid-stream reopens).
const streamingActive = !!(getState() as { streaming?: { bySession?: Record<string, unknown> } }).streaming?.bySession?.[sessionId];
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}`);
if (!res.ok) {
// 404: rehydrating a deleted/crashed session; structured reject lets .rejected purge state.
return rejectWithValue({ sessionId, status: res.status });
}
const session = await res.json();
(session as AgentSession & { _streamingActive?: boolean })._streamingActive = streamingActive;
return session as AgentSession;
}
);
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload, { dispatch }) => {
// Optimistic bubble on the DRAFT before the three round-trips (launch/message/refetch): without it the first message of every fresh chat rendered nothing until the network came back. The fulfilled rekey swaps in the server session, which carries the real turn by then.
const clientMessageId = _genOptimisticId();
dispatch(addOptimisticMessage({
sessionId: draftId,
clientMessageId,
prompt,
contextPaths,
forcedTools,
attachedSkills: attachedSkills?.map((s) => ({ id: s.id, name: s.name })),
images: images?.map((img) => ({ data: img.data, media_type: img.media_type })),
hidden: false,
}));
try {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const launchData = await launchRes.json();
const session = launchData.session as AgentSession;
// Only the launch response is load-bearing (it mints the session id); the message POST runs off the critical path so the rekey (and the chat's stream hookup) doesn't wait a round trip. The optimistic bubble already shows the message and flips to failed if this dies.
fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }),
}).then((res) => {
if (!res.ok) throw new Error(`first message failed: ${res.status}`);
}).catch(() => {
// The bubble lives on whichever session the rekey race left it in; one of these no-ops.
dispatch(markOptimisticFailed({ sessionId: session.id, clientMessageId }));
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
dispatch(updateSessionStatus({ sessionId: session.id, status: 'completed' }));
});
return { draftId, session };
} catch (err) {
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
throw err;
}
}
);
export const generateTitle = createAsyncThunk(
'agents/generateTitle',
async ({ sessionId, prompt }: { sessionId: string; prompt: string }) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const data = await res.json();
return { sessionId, title: data.title as string };
}
);
export interface GenerateGroupMetaPayload {
sessionId: string;
groupId: string;
toolCalls: Array<{ tool: string; input_summary: string }>;
resultsSummary?: string[];
isRefinement?: boolean;
}
export const generateGroupMeta = createAsyncThunk(
'agents/generateGroupMeta',
async ({ sessionId, groupId, toolCalls, resultsSummary, isRefinement }: GenerateGroupMetaPayload) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/generate-group-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
group_id: groupId,
tool_calls: toolCalls,
results_summary: resultsSummary,
is_refinement: isRefinement ?? false,
}),
});
const data = await res.json();
return { sessionId, groupId, name: data.name as string, svg: data.svg as string, isRefined: data.is_refined as boolean };
}
);
export const updateSystemPrompt = createAsyncThunk(
'agents/updateSystemPrompt',
async ({ sessionId, systemPrompt }: { sessionId: string; systemPrompt: string }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system_prompt: systemPrompt }),
});
return { sessionId, systemPrompt };
}
);
export const renameSession = createAsyncThunk(
'agents/rename',
async ({ sessionId, name }: { sessionId: string; name: string }, { dispatch }) => {
// Optimistic local update; the backend echoes the new name back over the agent:status broadcast, which keeps every open card in sync.
dispatch(updateSessionName({ sessionId, name }));
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return { sessionId, name };
}
);
export const updateThinkingLevel = createAsyncThunk(
'agents/updateThinkingLevel',
async ({ sessionId, level }: { sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thinking_level: level }),
});
return { sessionId, level };
}
);
export const handleApproval = createAsyncThunk(
'agents/handleApproval',
async ({
requestId,
behavior,
message,
updatedInput,
trustPattern,
setAlwaysAllow,
}: {
requestId: string;
behavior: 'allow' | 'deny';
message?: string;
updatedInput?: Record<string, any>;
trustPattern?: boolean;
setAlwaysAllow?: boolean;
}) => {
const res = await fetch(`${AGENTS_API}/approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput, trust_pattern: !!trustPattern, set_always_allow: !!setAlwaysAllow }),
});
if (!res.ok) {
throw new Error(`Approval request failed (${res.status})`);
}
return { requestId, behavior };
}
);
export const closeSession = createAsyncThunk(
'agents/closeSession',
async ({ sessionId }: { sessionId: string }) => {
// Drafts are client-only; the server has no such session and would 404.
if (!sessionId.startsWith('draft-')) {
await fetch(`${AGENTS_API}/sessions/${sessionId}/close`, { method: 'POST' });
}
return sessionId;
}
);
export const duplicateSession = createAsyncThunk(
'agents/duplicateSession',
async ({ sessionId, dashboardId, upToMessageId }: { sessionId: string; dashboardId?: string; upToMessageId?: string }) => {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dashboard_id: dashboardId, up_to_message_id: upToMessageId }),
});
if (!res.ok) throw new Error('Failed to duplicate session');
const data = await res.json();
return data.session as AgentSession;
}
);
export const deleteSession = createAsyncThunk(
'agents/deleteSession',
async ({ sessionId }: { sessionId: string }) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}`, { method: 'DELETE' });
return sessionId;
}
);
export const fetchHistory = createAsyncThunk(
'agents/fetchHistory',
async ({ dashboardId }: { dashboardId?: string } = {}) => {
// closed_only: an OPEN session landing in state.history made updateSession's resurrection gate swallow its terminal frames (card stuck running, final answer invisible). Search (searchHistory) keeps the full pool.
const params = new URLSearchParams({ limit: '10000', closed_only: '1' });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${AGENTS_API}/history?${params}`);
const data = await res.json();
return data.sessions as HistorySession[];
},
);
export interface SearchHistoryParams {
q?: string;
limit?: number;
offset?: number;
dashboardId?: string;
}
export const searchHistory = createAsyncThunk(
'agents/searchHistory',
async ({ q = '', limit = 20, offset = 0, dashboardId }: SearchHistoryParams) => {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset) });
if (dashboardId) params.set('dashboard_id', dashboardId);
const res = await fetch(`${AGENTS_API}/history?${params}`);
if (!res.ok) throw new Error(`history ${res.status}`);
const data = await res.json();
// A 500 body has no sessions array; without this the reducer stored undefined and the popover's .map took the whole dashboard down.
return {
sessions: Array.isArray(data.sessions) ? (data.sessions as HistorySession[]) : [],
total: typeof data.total === 'number' ? data.total : 0,
hasMore: !!data.has_more,
query: q,
offset,
};
}
);
export const resumeSession = createAsyncThunk(
'agents/resumeSession',
async ({ sessionId }: { sessionId: string }) => {
try {
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/resume`, { method: 'POST' });
const data = await res.json();
return data.session as AgentSession;
} catch (e: any) {
// eslint-disable-next-line no-console
console.error('[diag][thunk] resumeSession THREW', e && e.message);
throw e;
}
}
);
const agentsSlice = createSlice({
name: 'agents',
initialState,
reducers: {
createDraftSession: {
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }>) {
const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel, seededMessages, welcome, dashboardId } = action.payload;
state.sessions[draftId] = {
id: draftId,
name: welcome === true ? 'First chat with OpenSwarm' : 'New chat',
status: 'draft',
provider: provider || 'anthropic',
model: model || 'sonnet',
mode,
worktree_path: null,
branch_name: null,
sdk_session_id: null,
// Welcome drafts launch the first agent in an exploratory "narrow-down-then-do" mode.
system_prompt: welcome === true ? WELCOME_EXPLORATORY_PROMPT : null,
allowed_tools: [],
max_turns: null,
created_at: new Date().toISOString(),
cost_usd: 0,
tokens: { input: 0, output: 0 },
// A seeded greeting is purely cosmetic: launchAndSendFirstMessage.fulfilled deletes this draft and swaps in the raw server session, so seeded messages never reach the backend.
messages: seededMessages ?? [],
pending_approvals: [],
branches: { main: { id: 'main', parent_branch_id: null, fork_point_message_id: null, created_at: new Date().toISOString() } },
active_branch_id: 'main',
target_directory: targetDirectory || null,
tool_group_meta: {},
thinking_level: thinkingLevel,
dashboard_id: dashboardId,
is_welcome_draft: welcome === true,
};
if (setActive) {
state.activeSessionId = draftId;
if (!state.expandedSessionIds.includes(draftId)) {
state.expandedSessionIds.push(draftId);
}
}
},
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }) {
return {
payload: {
draftId: `draft-${Date.now().toString(36)}`,
mode: opts?.mode || 'agent',
setActive: opts?.setActive !== false,
targetDirectory: opts?.targetDirectory,
model: opts?.model,
provider: opts?.provider,
thinkingLevel: opts?.thinkingLevel,
seededMessages: opts?.seededMessages,
welcome: opts?.welcome,
dashboardId: opts?.dashboardId,
},
};
},
},
setActiveSession(state, action: PayloadAction<string | null>) {
state.activeSessionId = action.payload;
},
clearSessionMessages(state, action: PayloadAction<string>) {
const session = state.sessions[action.payload];
if (session) {
session.messages = [];
}
},
toggleExpandSession(state, action: PayloadAction<string>) {
const idx = state.expandedSessionIds.indexOf(action.payload);
if (idx >= 0) {
state.expandedSessionIds.splice(idx, 1);
} else {
state.expandedSessionIds.push(action.payload);
}
},
expandSession(state, action: PayloadAction<string>) {
if (!state.expandedSessionIds.includes(action.payload)) {
state.expandedSessionIds.push(action.payload);
}
},
collapseSession(state, action: PayloadAction<string>) {
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== action.payload);
},
collapseAllSessions(state) {
state.expandedSessionIds = [];
},
setExpandedSessionIds(state, action: PayloadAction<string[]>) {
state.expandedSessionIds = action.payload;
},
updateSessionName(state, action: PayloadAction<{ sessionId: string; name: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.name = normalizeSessionName(action.payload.name);
}
},
updateGroupMeta(
state,
action: PayloadAction<{ sessionId: string; groupId: string; name: string; svg: string; isRefined: boolean }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.tool_group_meta[action.payload.groupId] = {
id: action.payload.groupId,
name: action.payload.name,
svg: action.payload.svg,
is_refined: action.payload.isRefined,
};
}
},
setDraftSystemPrompt(state, action: PayloadAction<{ sessionId: string; systemPrompt: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session && session.status === 'draft') {
session.system_prompt = action.payload.systemPrompt;
}
},
updateSession(state, action: PayloadAction<AgentSession>) {
if (state.history[action.payload.id]) {
if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') {
delete state.history[action.payload.id];
} else if (!state.sessions[action.payload.id]) {
// Gate only truly-closed sessions (no live card): a late frame must not resurrect them. A LIVE session that leaked into history used to have its completed frame swallowed here, leaving the card stuck running.
return;
}
}
const existing = state.sessions[action.payload.id];
// Don't let stale "running" overwrite terminal status.
const terminal = ['stopped', 'error'] as const;
if (existing && terminal.includes(existing.status as any) && action.payload.status === 'running') {
return;
}
// Preserve local pending_approvals when server payload has none (race on removal).
const mergedApprovals = existing?.pending_approvals?.length && !action.payload.pending_approvals?.length
? existing.pending_approvals
: action.payload.pending_approvals ?? [];
state.sessions[action.payload.id] = {
...action.payload,
name: normalizeSessionName(action.payload.name),
// Status frames replay stale on WS reconnect; the transcript and branch set only move forward here (fetchSession owns server-side deletes).
messages: mergeSessionMessages(existing?.messages, action.payload.messages, false),
branches: { ...existing?.branches, ...action.payload.branches },
pending_approvals: mergedApprovals,
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
// Renderer-local transient pills; the wire payload never carries them, so a status frame mid-backoff would wipe the "provider busy" pill it exists to explain.
provider_retrying: existing?.provider_retrying ?? null,
rate_limited: existing?.rate_limited ?? null,
};
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.id)) {
state.trackedNotificationIds.push(action.payload.id);
}
},
updateSessionStatus(
state,
action: PayloadAction<{ sessionId: string; status: AgentSession['status'] }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
const terminal = ['stopped', 'error'] as const;
if (terminal.includes(session.status as any) && action.payload.status === 'running') {
return;
}
// A fresh turn clears last turn's deps-changed flag so the app card only hard-reloads for the turn that actually changed deps.
if (action.payload.status === 'running' && session.status !== 'running') {
session.app_deps_changed = false;
}
// Leaving the running state ends any admission-queue wait (the partial-payload path; the full-session path drops it by replacing the object).
if (action.payload.status !== 'running') session.queued = false;
session.status = action.payload.status;
}
if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) {
state.trackedNotificationIds.push(action.payload.sessionId);
}
},
setSessionTestState(
state,
action: PayloadAction<{ sessionId: string; state: 'running' | 'complete' | 'error' }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) session.workflow_test_state = action.payload.state;
},
setSessionConnState(
state,
action: PayloadAction<{ sessionId: string; state: 'live' | 'reconnecting' }>
) {
// Transient WS state, decoupled from session.status so blips don't mask the agent lifecycle.
const session = state.sessions[action.payload.sessionId];
if (session) {
session.connection_state = action.payload.state;
}
},
addMessage(state, action: PayloadAction<{ sessionId: string; message: AgentMessage }>) {
const session = state.sessions[action.payload.sessionId];
if (!session) return;
const incoming = action.payload.message;
// Optimistic-bubble dedupe by client_message_id.
if (incoming.client_message_id) {
const optIdx = session.messages.findIndex(
(m) => m.client_message_id === incoming.client_message_id && m.optimistic_status === 'pending',
);
if (optIdx >= 0) {
session.messages[optIdx] = { ...incoming, optimistic_status: undefined };
return;
}
}
const idx = session.messages.findIndex((m) => m.id === incoming.id);
if (idx >= 0) {
session.messages[idx] = incoming;
} else {
session.messages.push(incoming);
}
},
// Synchronous "you sent a message" placeholder; client_message_id round-trips for echo dedupe.
addOptimisticMessage(
state,
action: PayloadAction<{
sessionId: string;
clientMessageId: string;
prompt: string;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string }>;
images?: Array<{ data: string; media_type: string }>;
hidden?: boolean;
}>,
) {
const { sessionId, clientMessageId, prompt, contextPaths, forcedTools, attachedSkills, images, hidden } = action.payload;
const session = state.sessions[sessionId];
if (!session) return;
// Hidden messages (e.g. internal continuation prompts) skip the optimistic bubble.
if (hidden) return;
session.messages.push({
id: clientMessageId,
role: 'user',
content: prompt,
timestamp: new Date().toISOString(),
branch_id: session.active_branch_id,
parent_id: null,
context_paths: contextPaths,
attached_skills: attachedSkills,
forced_tools: forcedTools,
images,
client_message_id: clientMessageId,
optimistic_status: 'pending',
});
},
markOptimisticFailed(
state,
action: PayloadAction<{ sessionId: string; clientMessageId: string }>,
) {
const session = state.sessions[action.payload.sessionId];
if (!session) return;
const msg = session.messages.find(
(m) => m.client_message_id === action.payload.clientMessageId && m.optimistic_status === 'pending',
);
if (msg) msg.optimistic_status = 'failed';
},
// Mirror compacted_through_msg_id from agent:context_status so the renderer can drop a chip.
recordCompaction(
state,
action: PayloadAction<{ sessionId: string; throughMsgId: string | null }>,
) {
const session = state.sessions[action.payload.sessionId];
if (!session) return;
session.compacted_through_msg_id = action.payload.throughMsgId;
// After compaction the OLD context numbers are wrong (they count messages we just dropped). Reset EVERY context-derived field so all surfaces that show it — the toolbar ContextRing, the /context drawer %, the pre-send guard — reflect the post-compaction state, not just the token count. The real values refill on the next turn's round-trip; until then zero is closer than stale.
if (action.payload.throughMsgId) {
session.tokens = { input: 0, output: session.tokens?.output ?? 0 };
session.ctx_used_pct = 0;
session.cache_read_pct = 0;
session.cache_read_tokens = 0;
}
},
// Aux-LLM turn label; pill renderer prefers this over the static "Thinking..." verb.
setTurnLabel(
state,
action: PayloadAction<{ sessionId: string; turnId: string; label: string }>,
) {
const session = state.sessions[action.payload.sessionId];
if (!session) return;
session.turn_label = { label: action.payload.label, turn_id: action.payload.turnId };
},
clearTurnLabel(state, action: PayloadAction<string>) {
const session = state.sessions[action.payload];
if (!session) return;
session.turn_label = null;
},
setQueued(state, action: PayloadAction<{ sessionId: string; queued: boolean }>) {
const session = state.sessions[action.payload.sessionId];
if (!session) return;
session.queued = action.payload.queued;
},
// streamStart/Delta/End live in streamingSlice; keeps sessions dict stable during streaming.
addApprovalRequest(
state,
action: PayloadAction<{ sessionId: string; request: ApprovalRequest }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
const exists = session.pending_approvals.some((r) => r.id === action.payload.request.id);
if (!exists) {
session.pending_approvals.push(action.payload.request);
}
session.status = 'waiting_approval';
}
},
removeApprovalRequest(
state,
action: PayloadAction<{ sessionId: string; requestId: string }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.pending_approvals = session.pending_approvals.filter(
(r) => r.id !== action.payload.requestId
);
if (session.pending_approvals.length === 0 && session.status === 'waiting_approval') {
session.status = 'running';
}
}
},
updateSessionCost(
state,
action: PayloadAction<{ sessionId: string; costUsd: number }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.cost_usd = action.payload.costUsd;
}
},
updateSessionContext(
state,
action: PayloadAction<{
sessionId: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheReadPct: number;
ctxUsedPct: number;
contextWindow?: number;
frameworkOverheadTokens?: number;
activeMcps: string[];
}>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.tokens = {
...(session.tokens || {}),
input: action.payload.inputTokens,
output: action.payload.outputTokens,
};
session.cache_read_tokens = action.payload.cacheReadTokens;
session.cache_read_pct = action.payload.cacheReadPct;
session.ctx_used_pct = action.payload.ctxUsedPct;
if (typeof action.payload.contextWindow === 'number' && action.payload.contextWindow > 0) {
session.context_window = action.payload.contextWindow;
}
if (typeof action.payload.frameworkOverheadTokens === 'number') {
session.framework_overhead_tokens = action.payload.frameworkOverheadTokens;
}
session.active_mcps = action.payload.activeMcps;
}
},
setContextOverflow(
state,
action: PayloadAction<{
sessionId: string;
reason: string;
message: string;
}>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.context_overflow = {
reason: action.payload.reason,
message: action.payload.message,
at: new Date().toISOString(),
};
}
},
setRateLimited(
state,
action: PayloadAction<{ sessionId: string; retryAfterS: number | null }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.rate_limited = {
retry_after_s: action.payload.retryAfterS,
at: new Date().toISOString(),
};
}
},
setProviderRetrying(
state,
action: PayloadAction<{ sessionId: string; attempt: number | null; delayMs: number | null }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider_retrying = {
attempt: action.payload.attempt,
delay_ms: action.payload.delayMs,
at: new Date().toISOString(),
};
}
},
clearProviderRetrying(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.provider_retrying = null;
},
clearRateLimited(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.rate_limited = null;
},
setAppDepsChanged(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.app_deps_changed = true;
},
setContextRecovered(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.context_recovered = { at: new Date().toISOString() };
},
clearContextRecovered(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.context_recovered = null;
},
clearContextOverflow(
state,
action: PayloadAction<{ sessionId: string }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.context_overflow = null;
}
},
setMcpSuggestions(
state,
action: PayloadAction<{
sessionId: string;
suggestions: Array<{ id: string; title: string; description: string; reason?: string }>;
isVague: boolean;
}>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.mcp_suggestions = action.payload.suggestions;
session.mcp_suggestions_is_vague = action.payload.isVague;
}
},
clearMcpSuggestions(
state,
action: PayloadAction<{ sessionId: string }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.mcp_suggestions = [];
}
},
addBranch(state, action: PayloadAction<{ sessionId: string; branch: MessageBranch }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.branches[action.payload.branch.id] = action.payload.branch;
}
},
setActiveBranch(state, action: PayloadAction<{ sessionId: string; branchId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.active_branch_id = action.payload.branchId;
}
},
updateSessionProvider(state, action: PayloadAction<{ sessionId: string; provider: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider = action.payload.provider;
}
},
updateSessionModel(state, action: PayloadAction<{ sessionId: string; model: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.model = action.payload.model;
}
},
updateSessionMode(state, action: PayloadAction<{ sessionId: string; mode: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.mode = action.payload.mode;
}
},
updateSessionThinkingLevel(state, action: PayloadAction<{ sessionId: string; level: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.thinking_level = action.payload.level;
}
},
closeSessionFromWs(state, action: PayloadAction<HistorySession & { keepSession?: boolean }>) {
const { keepSession, ...entry } = action.payload;
state.history[entry.id] = entry;
const session = state.sessions[entry.id];
// keepSession: the user is watching this run live, so a workflow finishing shouldn't yank the chat out from under them. Keep it as a normal completed chat (continue / exit) instead of deleting the card.
if (keepSession && session) {
session.status = (entry.status as AgentSession['status']) || 'completed';
return;
}
if (session?.mode === 'browser-agent' && session.parent_session_id) {
session.status = (entry.status as AgentSession['status']) || 'completed';
} else {
delete state.sessions[entry.id];
for (const [id, s] of Object.entries(state.sessions)) {
if (s.mode === 'browser-agent' && s.parent_session_id === entry.id) {
s.status = 'stopped';
}
}
}
if (state.activeSessionId === entry.id) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== entry.id);
},
removeDraftSession(state, action: PayloadAction<string>) {
const id = action.payload;
const session = state.sessions[id];
if (session?.status === 'draft') {
delete state.sessions[id];
if (state.activeSessionId === id) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((eid) => eid !== id);
}
},
clearHistorySearch(state) {
state.historySearch = { results: [], total: 0, hasMore: false, query: '', loading: false };
},
trackAgentNotification(state, action: PayloadAction<string>) {
if (!state.trackedNotificationIds.includes(action.payload)) {
state.trackedNotificationIds.push(action.payload);
}
},
dismissAgentNotification(state, action: PayloadAction<string>) {
state.trackedNotificationIds = state.trackedNotificationIds.filter(
(id) => id !== action.payload,
);
},
dismissAllFinishedNotifications(state) {
const finishedStatuses = new Set(['completed', 'error', 'stopped']);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => {
const session = state.sessions[id];
if (session) return !finishedStatuses.has(session.status);
const hist = state.history[id];
if (hist) return !finishedStatuses.has(hist.status);
return true;
});
},
},
extraReducers: (builder) => {
builder
.addCase(fetchSessions.pending, (state) => {
state.loading = true;
})
.addCase(fetchSessions.fulfilled, (state, action) => {
state.loading = false;
const fetchedIds = new Set(action.payload.map((s) => s.id));
const activeStatuses = new Set(['running', 'waiting_approval']);
// Strip sessions the server no longer has, but a dashboard's list is only authoritative for ITS OWN sessions: hopping dashboards must not eat the finished chat you were just reading (it looked like wiped history).
const fetchedDashboardId = action.meta.arg?.dashboardId;
// No scope = no authority to delete ANYTHING. An unscoped answer is memory-only on the
// backend (empty right after a respawn), and one such payload stripping globally is the
// start of the wipe chain: store emptied -> reconcile deletes every card -> debounced save
// persists it, permanently, because card-less sessions are never promoted again (ENG-271).
if (fetchedDashboardId) {
for (const [id, existing] of Object.entries(state.sessions)) {
if (fetchedIds.has(id)) continue;
if (existing.dashboard_id !== fetchedDashboardId) continue;
if (existing.status === 'draft') continue;
if (state.trackedNotificationIds.includes(id)) continue;
if (activeStatuses.has(existing.status)) continue;
delete state.sessions[id];
}
}
// Merge fetched sessions, preserving local-only fields
for (const s of action.payload) {
const existing = state.sessions[s.id];
state.sessions[s.id] = {
...s,
name: normalizeSessionName(s.name),
// This is a METADATA poll (status/name); the chat owns its messages via fetchSession + the WS stream. A poll response computed before a just-sent user turn must NOT clobber the live array, that intermittently wiped the user's own bubble (while the assistant stream, a separate slice, kept rendering). Keep the hydrated messages; only adopt the poll's copy for a session we haven't loaded into the chat yet.
messages: existing?.messages?.length ? existing.messages : s.messages ?? [],
pending_approvals: existing?.pending_approvals?.length
? existing.pending_approvals
: s.pending_approvals ?? [],
tool_group_meta: { ...existing?.tool_group_meta, ...s.tool_group_meta },
mcp_suggestions: existing?.mcp_suggestions ?? [],
mcp_suggestions_is_vague: existing?.mcp_suggestions_is_vague ?? false,
};
if (activeStatuses.has(s.status) && !state.trackedNotificationIds.includes(s.id)) {
state.trackedNotificationIds.push(s.id);
}
}
})
.addCase(fetchSessions.rejected, (state) => {
state.loading = false;
})
.addCase(launchAgent.fulfilled, (state, action) => {
state.sessions[action.payload.id] = { ...action.payload, name: normalizeSessionName(action.payload.name), tool_group_meta: action.payload.tool_group_meta ?? {}, pending_approvals: action.payload.pending_approvals ?? [] };
state.activeSessionId = action.payload.id;
if (!state.expandedSessionIds.includes(action.payload.id)) {
state.expandedSessionIds.push(action.payload.id);
}
if (!state.trackedNotificationIds.includes(action.payload.id)) {
state.trackedNotificationIds.push(action.payload.id);
}
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const shouldExpand = action.meta.arg.expand !== false;
// The swap uses the LAUNCH response (no refetch round trip), so the user's message exists only as the draft's optimistic bubble; carry it (never the seeded greeting, which is cosmetic and must not reach the server session) plus anything the WS already landed under the server id.
const carried = [
...(state.sessions[session.id]?.messages ?? []),
...(state.sessions[draftId]?.messages ?? []).filter((m) => m.optimistic_status),
];
delete state.sessions[draftId];
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
// The first message POST is in flight; its failure path flips this back (same optimism as sendMessage.pending).
status: 'running',
messages: mergeSessionMessages(carried, session.messages, false),
tool_group_meta: session.tool_group_meta ?? {},
pending_approvals: session.pending_approvals ?? [],
};
state.activeSessionId = session.id;
state.draftLaunchMap[draftId] = session.id;
state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id));
if (shouldExpand && !state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);
}
if (!state.trackedNotificationIds.includes(session.id)) {
state.trackedNotificationIds.push(session.id);
}
})
.addCase(generateTitle.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.name = normalizeSessionName(action.payload.title);
}
})
.addCase(generateGroupMeta.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.tool_group_meta[action.payload.groupId] = {
id: action.payload.groupId,
name: action.payload.name,
svg: action.payload.svg,
is_refined: action.payload.isRefined,
};
}
})
.addCase(updateSystemPrompt.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.system_prompt = action.payload.systemPrompt;
}
})
.addCase(sendMessage.pending, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session) {
session.status = 'running';
}
})
.addCase(editMessage.pending, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session) {
session.status = 'running';
}
})
// The optimistic .pending above set status='running'; on a failed send/edit nothing ever cleared it, so the input stayed locked forever. Release it. Guard on 'running' so a status the WS already advanced isn't clobbered, and use 'completed' (not a blocked terminal) so a later WS 'running' can still take if the agent did start.
.addCase(sendMessage.rejected, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session && session.status === 'running') {
session.status = 'completed';
}
})
.addCase(editMessage.rejected, (state, action) => {
const session = state.sessions[action.meta.arg.sessionId];
if (session && session.status === 'running') {
session.status = 'completed';
}
})
.addCase(stopAgent.fulfilled, (state, action) => {
const session = state.sessions[action.payload];
if (session) {
session.status = 'stopped';
session.pending_approvals = [];
// streamingMessage cleanup is via clearStreamingForSession (not in streamingSlice's extraReducers).
}
})
.addCase(handleApproval.fulfilled, (state, action) => {
for (const session of Object.values(state.sessions)) {
session.pending_approvals = session.pending_approvals.filter(
(r) => r.id !== action.payload.requestId
);
}
})
.addCase(handleApproval.rejected, (_state, action) => {
// Approval stays in state so the user can retry; request never reached the backend.
console.error('Approval request failed:', action.error.message);
})
.addCase(switchBranch.fulfilled, (state, action) => {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.active_branch_id = action.payload.branchId;
}
})
.addCase(duplicateSession.fulfilled, (state, action) => {
const session = action.payload;
state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), pending_approvals: session.pending_approvals ?? [] };
})
.addCase(closeSession.fulfilled, (state, action) => {
const sessionId = action.payload;
const session = state.sessions[sessionId];
if (session) {
state.history[sessionId] = {
id: session.id,
name: session.name,
status: session.status === 'running' || session.status === 'waiting_approval' ? 'stopped' : session.status,
model: session.model,
mode: session.mode,
created_at: session.created_at,
closed_at: new Date().toISOString(),
cost_usd: session.cost_usd,
dashboard_id: session.dashboard_id,
};
}
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(closeSession.rejected, (state, action) => {
const sessionId = action.meta.arg.sessionId;
const session = state.sessions[sessionId];
if (session) {
state.history[sessionId] = {
id: session.id,
name: session.name,
status: session.status === 'running' || session.status === 'waiting_approval' ? 'stopped' : session.status,
model: session.model,
mode: session.mode,
created_at: session.created_at,
closed_at: new Date().toISOString(),
cost_usd: session.cost_usd,
dashboard_id: session.dashboard_id,
};
}
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(deleteSession.fulfilled, (state, action) => {
const sessionId = action.payload;
delete state.history[sessionId];
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
})
.addCase(fetchHistory.fulfilled, (state, action) => {
const history: Record<string, HistorySession> = {};
for (const s of action.payload) {
history[s.id] = s;
}
state.history = history;
})
.addCase(resumeSession.fulfilled, (state, action) => {
const session = action.payload;
state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] };
delete state.history[session.id];
state.activeSessionId = session.id;
if (!state.expandedSessionIds.includes(session.id)) {
state.expandedSessionIds.push(session.id);
}
// Pin across the next fetchSessions strip so an in-flight fetch can't drop the just-resumed session.
if (!state.trackedNotificationIds.includes(session.id)) {
state.trackedNotificationIds.push(session.id);
}
})
.addCase(fetchSession.fulfilled, (state, action) => {
const session = action.payload;
const existing = state.sessions[session.id];
// Preserve local messages the server snapshot doesn't carry yet. On remount mid-stream (leave the chat + come back) this fetch's snapshot predates the just-sent user turn, so a blind replace wiped the user's own bubble while the assistant stream (separate slice) kept going. The WS echo clears optimistic_status the instant it arrives, so the message is usually "confirmed but not yet server-persisted" rather than still 'pending' (that's why a pending-only filter missed it). Gate on the session being LIVE: on a running/streaming session, carry forward any local message the snapshot lacks; on a settled session the snapshot is authoritative (so a server-side delete isn't resurrected).
// Live by EITHER side's account: a send on a completed chat flips local status to running while the racing snapshot still says completed and lacks the new turn; trusting only the snapshot wiped the user bubble until the run finished.
const isLive = (s?: string) => s === 'running' || s === 'waiting_approval';
// A streaming session counts as live even if neither status says 'running' (streaming lives in streamingSlice). Without this, a mid-stream reopen dropped the just-sent user bubble until the turn finished.
const streamingActive = !!(session as AgentSession & { _streamingActive?: boolean })._streamingActive;
const liveStatus = streamingActive || isLive(session.status) || isLive(existing?.status);
delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive;
// Deletes only apply on a settled session: a snapshot racing a live turn is stale, not authoritative.
const stableMessages = mergeSessionMessages(existing?.messages, session.messages, !liveStatus);
state.sessions[session.id] = {
...session,
name: normalizeSessionName(session.name),
messages: stableMessages,
pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [],
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
// mcp_suggestions live in client state only (the backend never returns them in the session payload). Preserve them across refresh so the suggestion banner stays put until the user dismisses it or activates one.
mcp_suggestions: existing?.mcp_suggestions ?? [],
mcp_suggestions_is_vague: existing?.mcp_suggestions_is_vague ?? false,
};
})
.addCase(fetchSession.rejected, (state, action) => {
// Stale-id cleanup on 404/410: strip so AgentChat short-circuits instead of looping the dead fetch.
const payload = action.payload as { sessionId?: string; status?: number } | undefined;
const sessionId = payload?.sessionId;
if (!sessionId) return;
if (payload?.status === 404 || payload?.status === 410) {
delete state.sessions[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== sessionId);
state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => id !== sessionId);
}
})
.addCase(searchHistory.pending, (state) => {
state.historySearch.loading = true;
})
.addCase(searchHistory.fulfilled, (state, action) => {
const { sessions, total, hasMore, query, offset } = action.payload;
if (offset === 0) {
state.historySearch.results = sessions;
} else {
state.historySearch.results = [...state.historySearch.results, ...sessions];
}
state.historySearch.total = total;
state.historySearch.hasMore = hasMore;
state.historySearch.query = query;
state.historySearch.loading = false;
})
.addCase(searchHistory.rejected, (state) => {
state.historySearch.loading = false;
});
},
});
export const {
createDraftSession,
setActiveSession,
clearSessionMessages,
toggleExpandSession,
expandSession,
collapseSession,
collapseAllSessions,
setExpandedSessionIds,
updateSessionName,
updateGroupMeta,
setDraftSystemPrompt,
updateSession,
updateSessionStatus,
setSessionConnState,
setSessionTestState,
addMessage,
addOptimisticMessage,
markOptimisticFailed,
recordCompaction,
setTurnLabel,
clearTurnLabel,
setQueued,
addApprovalRequest,
removeApprovalRequest,
updateSessionCost,
updateSessionContext,
setContextOverflow,
setRateLimited,
clearRateLimited,
setProviderRetrying,
clearProviderRetrying,
setContextRecovered,
clearContextRecovered,
setAppDepsChanged,
clearContextOverflow,
setMcpSuggestions,
clearMcpSuggestions,
addBranch,
setActiveBranch,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
updateSessionThinkingLevel,
closeSessionFromWs,
removeDraftSession,
clearHistorySearch,
trackAgentNotification,
dismissAgentNotification,
dismissAllFinishedNotifications,
} = agentsSlice.actions;
export default agentsSlice.reducer;