import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { API_BASE } from '@/shared/config'; import { normalizeSessionName } from './sessionDisplay'; const AGENTS_API = `${API_BASE}/agents`; 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; 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'; 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[]; pending_approvals: ApprovalRequest[]; branches: Record; active_branch_id: string; // streamingMessage lives in state.streaming.bySession[id]; see streamingSlice. target_directory?: string | null; tool_group_meta: Record; dashboard_id?: string; browser_id?: string | null; parent_session_id?: string | null; /** Browser memory signals that drive the subtle "Remembered"/"Learned" card chip. */ memory_recalled?: boolean; memory_learned?: 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; 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; } 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; history: Record; 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; } 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', async ({ dashboardId }: { dashboardId?: string } = {}) => { const params = new URLSearchParams(); if (dashboardId) params.set('dashboard_id', dashboardId); const qs = params.toString(); const res = await fetch(`${AGENTS_API}/sessions${qs ? `?${qs}` : ''}`); 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[]; } 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 }: 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, 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 }; } ); 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[]; } 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 } }).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 }: LaunchAndSendPayload) => { 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; await 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 }), }); const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`); const updatedSession = await refreshRes.json() as AgentSession; return { draftId, session: updatedSession }; } ); 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 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, }: { requestId: string; behavior: 'allow' | 'deny'; message?: string; updatedInput?: Record; trustPattern?: 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 }), }); if (!res.ok) { throw new Error(`Approval request failed (${res.status})`); } return { requestId, behavior }; } ); export const closeSession = createAsyncThunk( 'agents/closeSession', async ({ sessionId }: { sessionId: string }) => { 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 } = {}) => { const params = new URLSearchParams({ limit: '10000' }); 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}`); const data = await res.json(); return { sessions: data.sessions as HistorySession[], total: data.total as number, hasMore: data.has_more as boolean, 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; } } ); export const fetchBrowserAgentChildren = createAsyncThunk( 'agents/fetchBrowserAgentChildren', async (parentSessionId: string) => { const res = await fetch(`${AGENTS_API}/sessions/${parentSessionId}/browser-agents`); const data = await res.json(); return data.sessions as AgentSession[]; } ); 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: 'New chat', status: 'draft', provider: provider || 'anthropic', model: model || 'sonnet', mode, worktree_path: null, branch_name: null, sdk_session_id: null, system_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) { state.activeSessionId = action.payload; }, clearSessionMessages(state, action: PayloadAction) { const session = state.sessions[action.payload]; if (session) { session.messages = []; } }, toggleExpandSession(state, action: PayloadAction) { 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) { if (!state.expandedSessionIds.includes(action.payload)) { state.expandedSessionIds.push(action.payload); } }, collapseSession(state, action: PayloadAction) { state.expandedSessionIds = state.expandedSessionIds.filter((id) => id !== action.payload); }, collapseAllSessions(state) { state.expandedSessionIds = []; }, setExpandedSessionIds(state, action: PayloadAction) { 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) { if (state.history[action.payload.id]) { if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') { delete state.history[action.payload.id]; } else { 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), pending_approvals: mergedApprovals, tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta }, }; 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; } session.status = action.payload.status; } if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) { state.trackedNotificationIds.push(action.payload.sessionId); } }, 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) { const session = state.sessions[action.payload]; if (!session) return; session.turn_label = null; }, // 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(), }; } }, 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) { const entry = action.payload; state.history[entry.id] = entry; const session = state.sessions[entry.id]; 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) { 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) { if (!state.trackedNotificationIds.includes(action.payload)) { state.trackedNotificationIds.push(action.payload); } }, dismissAgentNotification(state, action: PayloadAction) { 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; for (const [id, existing] of Object.entries(state.sessions)) { if (fetchedIds.has(id)) continue; if (fetchedDashboardId && 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 ?? {} }; 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; delete state.sessions[draftId]; state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {} }; 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) }; }) .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 = {}; 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 ?? {} }; 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). const incomingMsgs = session.messages ?? []; // 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); const incomingClientIds = new Set( incomingMsgs.map((m) => m.client_message_id).filter(Boolean), ); const incomingIds = new Set(incomingMsgs.map((m) => m.id)); // An optimistic message (no WS echo yet) is preserved even when both sides // read settled: right after a send on a completed chat, NEITHER status has // flipped to running, and the racing snapshot wiped the just-typed bubble // for seconds. It can't be a deleted-message resurrection; the server has // never confirmed it existed. const surviving = (existing?.messages ?? []).filter( (m) => (liveStatus || m.optimistic_status) && !incomingIds.has(m.id) && !(m.client_message_id && incomingClientIds.has(m.client_message_id)), ); // Place survivors by timestamp, not blindly at the end: when the snapshot // already carries the agent's reply, appending the just-sent user bubble // rendered the OUTPUT above the INPUT. Insert before the first incoming // message that is newer. const mergedMessages = surviving.length ? [...incomingMsgs] : incomingMsgs; for (const m of surviving) { const at = mergedMessages.findIndex((x) => (x.timestamp || '') > (m.timestamp || '')); if (at === -1) mergedMessages.push(m); else mergedMessages.splice(at, 0, m); } delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive; state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), messages: mergedMessages, 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(fetchBrowserAgentChildren.fulfilled, (state, action) => { for (const session of action.payload) { if (!state.sessions[session.id]) { state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, }; } } }) .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, addMessage, addOptimisticMessage, markOptimisticFailed, recordCompaction, setTurnLabel, clearTurnLabel, addApprovalRequest, removeApprovalRequest, updateSessionCost, updateSessionContext, setContextOverflow, clearContextOverflow, setMcpSuggestions, clearMcpSuggestions, addBranch, setActiveBranch, updateSessionProvider, updateSessionModel, updateSessionMode, updateSessionThinkingLevel, closeSessionFromWs, removeDraftSession, clearHistorySearch, trackAgentNotification, dismissAgentNotification, dismissAllFinishedNotifications, } = agentsSlice.actions; export default agentsSlice.reducer;