import { create } from 'zustand' import { wsManager } from '../api/websocket' import { sessionsApi } from '../api/sessions' import { useTeamStore } from './teamStore' import { useSessionStore } from './sessionStore' import { useCLITaskStore } from './cliTaskStore' import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle' import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { ComposerAttachment } from '../lib/composerAttachments' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { RuntimeSelection } from '../types/runtime' import type { ActiveGoalState, AgentTaskNotification, ApiRetryState, AttachmentRef, BackgroundAgentTask, BackgroundAgentTaskUsage, ChatState, ComputerUsePermissionRequest, ComputerUsePermissionResponse, GoalEventAction, MemoryEventFile, UIAttachment, UIMessage, ServerMessage, TokenUsage, } from '../types/chat' type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' type ToolCall = Extract type CompactSummaryMessage = Extract export type ComposerDraftState = { input: string attachments: ComposerAttachment[] } export type PerSessionState = { messages: UIMessage[] chatState: ChatState connectionState: ConnectionState streamingText: string streamingToolInput: string activeToolUseId: string | null activeToolName: string | null activeThinkingId: string | null pendingPermission: { requestId: string toolName: string toolUseId?: string input: unknown description?: string } | null pendingComputerUsePermission: { requestId: string request: ComputerUsePermissionRequest } | null tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string apiRetry?: ApiRetryState | null slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record backgroundAgentTasks?: Record activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { text: string attachments?: UIAttachment[] nonce: number } | null composerDraft?: ComposerDraftState | null } const DEFAULT_SESSION_STATE: PerSessionState = { messages: [], chatState: 'idle', connectionState: 'disconnected', streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', apiRetry: null, slashCommands: [], agentTaskNotifications: {}, backgroundAgentTasks: {}, activeGoal: null, elapsedTimer: null, composerPrefill: null, composerDraft: null, } function createDefaultSessionState(): PerSessionState { return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } } } type ChatStore = { sessions: Record getSession: (sessionId: string) => PerSessionState connectToSession: (sessionId: string) => void disconnectSession: (sessionId: string) => void sendMessage: ( sessionId: string, content: string, attachments?: AttachmentRef[], options?: { displayContent?: string; displayAttachments?: AttachmentRef[] }, ) => void respondToPermission: ( sessionId: string, requestId: string, allowed: boolean, options?: { rule?: string updatedInput?: Record }, ) => void respondToComputerUsePermission: ( sessionId: string, requestId: string, response: ComputerUsePermissionResponse, ) => void setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void stopGeneration: (sessionId: string) => void loadHistory: (sessionId: string) => Promise reloadHistory: (sessionId: string) => Promise queueComposerPrefill: ( sessionId: string, prefill: { text: string; attachments?: UIAttachment[] }, ) => void setComposerDraft: (sessionId: string, draft: ComposerDraftState) => void clearComposerDraft: (sessionId: string) => void clearMessages: (sessionId: string) => void handleServerMessage: (sessionId: string, msg: ServerMessage) => void } const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const TASK_STOP_TOOL_NAMES = new Set(['TaskStop', 'KillShell']) const pendingTaskToolUseIdsBySession = new Map>() const pendingToolParentUseIdsBySession = new Map>() function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void { const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set() ids.add(toolUseId) pendingTaskToolUseIdsBySession.set(sessionId, ids) } function consumePendingTaskToolUseId(sessionId: string, toolUseId: string): boolean { const ids = pendingTaskToolUseIdsBySession.get(sessionId) if (!ids?.has(toolUseId)) return false ids.delete(toolUseId) if (ids.size === 0) pendingTaskToolUseIdsBySession.delete(sessionId) return true } function clearPendingTaskToolUseIds(sessionId: string): void { pendingTaskToolUseIdsBySession.delete(sessionId) } function rememberPendingToolParentUseId( sessionId: string, toolUseId: string | null | undefined, parentToolUseId: string | undefined, ): void { if (!toolUseId || !parentToolUseId) return const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId) ?? new Map() parentUseIds.set(toolUseId, parentToolUseId) pendingToolParentUseIdsBySession.set(sessionId, parentUseIds) } function getPendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined { return pendingToolParentUseIdsBySession.get(sessionId)?.get(toolUseId) } function consumePendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined { const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId) if (!parentUseIds) return undefined const parentToolUseId = parentUseIds.get(toolUseId) parentUseIds.delete(toolUseId) if (parentUseIds.size === 0) pendingToolParentUseIdsBySession.delete(sessionId) return parentToolUseId } function clearPendingToolParentUseIds(sessionId: string): void { pendingToolParentUseIdsBySession.delete(sessionId) } const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160 const COMPACT_SUMMARY_PREFIX = 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.' const COMPACT_SUMMARY_CUTOFFS = [ '\n\nIf you need specific details from before compaction', '\n\nContinue the conversation from where it left off', '\nContinue the conversation from where it left off', ] let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } function readJsonStringLiteral(source: string, quoteIndex: number): string | undefined { if (source[quoteIndex] !== '"') return undefined let value = '' for (let index = quoteIndex + 1; index < source.length; index += 1) { const char = source[index] if (char === '\\') { const escaped = source[index + 1] if (escaped === undefined) return undefined value += char + escaped index += 1 continue } if (char === '"') { try { return JSON.parse(`"${value}"`) as string } catch { return value } } value += char } return undefined } function extractPartialJsonStringField(source: string, field: string): string | undefined { const key = `"${field}"` const keyIndex = source.indexOf(key) if (keyIndex < 0) return undefined const colonIndex = source.indexOf(':', keyIndex + key.length) if (colonIndex < 0) return undefined let valueIndex = colonIndex + 1 while (valueIndex < source.length && /\s/.test(source[valueIndex] ?? '')) { valueIndex += 1 } return readJsonStringLiteral(source, valueIndex) } function buildPartialToolInputPreview( partialInput: string, previousInput: unknown, ): Record { const previous = isRecord(previousInput) ? previousInput : {} const preview: Record = { ...previous } for (const field of ['file_path', 'filePath', 'path', 'command', 'pattern', 'url', 'query', 'description']) { const value = extractPartialJsonStringField(partialInput, field) if (value !== undefined) { preview[field] = value } } return preview } function upsertToolUseMessage( messages: UIMessage[], toolUseId: string, build: (existing?: ToolCall) => ToolCall, ): UIMessage[] { const existingIndex = messages.findIndex( (message): message is ToolCall => message.type === 'tool_use' && message.toolUseId === toolUseId, ) if (existingIndex < 0) { return [...messages, build()] } const next = [...messages] next[existingIndex] = build(messages[existingIndex] as ToolCall) return next } // Streaming throttle for content_delta. Buffers must be per-session because // multiple desktop tabs can stream at the same time. const pendingDeltaBySession = new Map() const flushTimerBySession = new Map>() function consumePendingDelta(sessionId: string): string { const flushTimer = flushTimerBySession.get(sessionId) if (flushTimer) { clearTimeout(flushTimer) flushTimerBySession.delete(sessionId) } const text = pendingDeltaBySession.get(sessionId) ?? '' pendingDeltaBySession.delete(sessionId) return text } function appendPendingDelta(sessionId: string, text: string): void { pendingDeltaBySession.set( sessionId, `${pendingDeltaBySession.get(sessionId) ?? ''}${text}`, ) } function clearPendingDelta(sessionId: string): void { const flushTimer = flushTimerBySession.get(sessionId) if (flushTimer) { clearTimeout(flushTimer) flushTimerBySession.delete(sessionId) } pendingDeltaBySession.delete(sessionId) } function appendAssistantTextMessage( messages: UIMessage[], content: string, timestamp: number, model?: string, transcriptMessageId?: string, ): UIMessage[] { if (!content.trim()) return messages const last = messages[messages.length - 1] const canMergeIntoLast = last?.type === 'assistant_text' && ( transcriptMessageId ? last.transcriptMessageId === transcriptMessageId : !last.transcriptMessageId ) if (canMergeIntoLast) { const merged: UIMessage = { ...last, content: last.content + content, ...(model ?? last.model ? { model: model ?? last.model } : {}), ...(transcriptMessageId ?? last.transcriptMessageId ? { transcriptMessageId: transcriptMessageId ?? last.transcriptMessageId } : {}), } return [...messages.slice(0, -1), merged] } return [ ...messages, { id: nextId(), type: 'assistant_text', content, timestamp, ...(transcriptMessageId ? { transcriptMessageId } : {}), ...(model ? { model } : {}), }, ] } function extractCompactSummaryContent(content: unknown): string | null { if (typeof content !== 'string') return null const trimmed = content.trim() if (!trimmed.startsWith(COMPACT_SUMMARY_PREFIX)) return null let summary = trimmed.slice(COMPACT_SUMMARY_PREFIX.length).trim() for (const marker of COMPACT_SUMMARY_CUTOFFS) { const index = summary.indexOf(marker) if (index >= 0) { summary = summary.slice(0, index).trim() } } return summary || null } function compactMetadataFromUnknown(data: unknown): Pick { if (!data || typeof data !== 'object') return {} const record = data as Record const trigger = record.trigger === 'manual' || record.trigger === 'auto' ? record.trigger : undefined const preTokens = typeof record.preTokens === 'number' ? record.preTokens : typeof record.pre_tokens === 'number' ? record.pre_tokens : undefined const messagesSummarized = typeof record.messagesSummarized === 'number' ? record.messagesSummarized : typeof record.messages_summarized === 'number' ? record.messages_summarized : undefined return { ...(trigger ? { trigger } : {}), ...(preTokens !== undefined ? { preTokens } : {}), ...(messagesSummarized !== undefined ? { messagesSummarized } : {}), } } function appendOrUpdateTailCompactSummary( messages: UIMessage[], update: Partial>, timestamp: number, ): UIMessage[] { const existingIndex = messages.length - 1 const existingMessage = messages[existingIndex] if (existingMessage?.type === 'compact_summary') { const existing = existingMessage const next: CompactSummaryMessage = { ...existing, ...update, title: update.title ?? existing.title, timestamp: existing.timestamp, } return [ ...messages.slice(0, existingIndex), next, ...messages.slice(existingIndex + 1), ] } return [ ...messages, { id: nextId(), type: 'compact_summary', title: update.title ?? 'Context compacted', ...update, timestamp, }, ] } function dropTailCompactingCompactSummary(messages: UIMessage[]): UIMessage[] { const tail = messages[messages.length - 1] if (tail?.type === 'compact_summary' && tail.phase === 'compacting') { return messages.slice(0, -1) } return messages } function upsertBackgroundTaskMessage( messages: UIMessage[], task: BackgroundAgentTask, timestamp: number, ): UIMessage[] { const isSameTaskMessage = (message: UIMessage) => message.type === 'background_task' && (message.task.taskId === task.taskId || (task.toolUseId && message.task.toolUseId === task.toolUseId)) if (isAgentBackgroundTask(task)) { return messages.filter((message) => !isSameTaskMessage(message)) } const existingIndex = messages.findIndex((message) => isSameTaskMessage(message)) if (existingIndex === -1) { return [...messages, { id: `background-task-${task.taskId}`, type: 'background_task', task, timestamp, }] } return messages.map((message, index) => index === existingIndex && message.type === 'background_task' ? { ...message, task: { ...message.task, ...task }, timestamp: message.timestamp || timestamp } : message) } function mergeBackgroundTaskMessages( messages: UIMessage[], tasks: Record, ): UIMessage[] { const merged = Object.values(tasks).reduce( (current, task) => upsertBackgroundTaskMessage(current, task, task.updatedAt), messages, ) return [...merged].sort((a, b) => a.timestamp - b.timestamp) } function isAgentBackgroundTask(task: Pick): boolean { if (task.taskType === 'local_agent' || task.taskType === 'remote_agent') { return true } return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test( task.summary ?? '', ) } function mergeRestoredTerminalGoalEvents( messages: UIMessage[], restoredMessages: UIMessage[], ): UIMessage[] { const existingKeys = new Set(messages .filter((message): message is Extract => message.type === 'goal_event') .map((message) => `${message.action}:${message.message ?? ''}:${message.objective ?? ''}`)) const missingTerminalEvents = restoredMessages.filter(( message, ): message is Extract => message.type === 'goal_event' && (message.action === 'completed' || message.action === 'cleared') && !existingKeys.has(`${message.action}:${message.message ?? ''}:${message.objective ?? ''}`)) return missingTerminalEvents.length > 0 ? [...messages, ...missingTerminalEvents] : messages } function mergeRestoredTranscriptMessageIds( messages: UIMessage[], restoredMessages: UIMessage[], ): UIMessage[] { const restoredCandidates = restoredMessages.filter(( message, ): message is Extract => (message.type === 'user_text' || message.type === 'assistant_text') && typeof message.transcriptMessageId === 'string' && message.transcriptMessageId.length > 0) if (restoredCandidates.length === 0) return messages let restoredCursor = 0 let changed = false const merged = messages.map((message) => { if ( (message.type !== 'user_text' && message.type !== 'assistant_text') || message.transcriptMessageId ) { return message } const matchIndex = restoredCandidates.findIndex((candidate, index) => index >= restoredCursor && candidate.type === message.type && candidate.content.trim() === message.content.trim()) if (matchIndex === -1) return message restoredCursor = matchIndex + 1 changed = true return { ...message, transcriptMessageId: restoredCandidates[matchIndex]!.transcriptMessageId, } }) return changed ? merged : messages } function mergeRestoredHistoryIntoLiveMessages( messages: UIMessage[], restoredMessages: UIMessage[], ): UIMessage[] { return mergeRestoredTerminalGoalEvents( mergeRestoredTranscriptMessageIds(messages, restoredMessages), restoredMessages, ) } function needsTranscriptIdHydrationRetry(session: PerSessionState | undefined): boolean { if (!session || session.chatState !== 'idle') return false let currentTurnHasHydratedUser = false for (const message of session.messages) { if (message.type === 'user_text') { currentTurnHasHydratedUser = Boolean(message.transcriptMessageId) continue } if ( currentTurnHasHydratedUser && message.type === 'assistant_text' && !message.transcriptMessageId ) { return true } } return false } function refreshCompletedTranscriptHistory( get: () => ChatStore, sessionId: string, ): void { void get().loadHistory(sessionId).then(() => { if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return setTimeout(() => { if (!needsTranscriptIdHydrationRetry(get().sessions[sessionId])) return void get().loadHistory(sessionId) }, 750) }) } function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] { if (!data || typeof data !== 'object') return [] const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths if (!Array.isArray(writtenPaths)) return [] return writtenPaths .filter((path): path is string => typeof path === 'string' && path.trim().length > 0) .map((path) => ({ path, action: 'saved' as const })) } function normalizeMemoryTeamCount(data: unknown): number | undefined { if (!data || typeof data !== 'object') return undefined const teamCount = (data as { teamCount?: unknown }).teamCount return typeof teamCount === 'number' && Number.isFinite(teamCount) ? teamCount : undefined } function normalizeNotificationPreview(content: string): string { return content .replace(/```[\s\S]*?```/g, ' code block ') .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/[*_~>#-]+/g, ' ') .replace(/\s+/g, ' ') .trim() } function buildAgentCompletionNotification( sessionId: string, messages: UIMessage[], text: string, ): { title: string; body: string; dedupeKey: string } | null { const preview = normalizeNotificationPreview(text) if (!preview) return null const lastAssistant = [...messages].reverse().find((message) => message.type === 'assistant_text') const suffix = preview.length > AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS ? '...' : '' return { title: 'Claude Code Haha 已完成回复', body: preview.slice(0, AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS) + suffix, dedupeKey: `agent-completion:${sessionId}:${lastAssistant?.id ?? Date.now()}`, } } /** Helper: immutably update a specific session within the sessions record */ function updateSessionIn( sessions: Record, sessionId: string, updater: (s: PerSessionState) => Partial, ): Record { const session = sessions[sessionId] if (!session) return sessions return { ...sessions, [sessionId]: { ...session, ...updater(session) } } } type SlashCommandState = PerSessionState['slashCommands'][number] function normalizeSlashCommand(command: unknown): SlashCommandState | null { if (!command || typeof command !== 'object') return null const candidate = command as { name?: unknown; description?: unknown; argumentHint?: unknown } if (typeof candidate.name !== 'string' || !candidate.name) return null return { name: candidate.name, description: typeof candidate.description === 'string' ? candidate.description : '', ...(typeof candidate.argumentHint === 'string' && candidate.argumentHint ? { argumentHint: candidate.argumentHint } : {}), } } function normalizeSlashCommandList(commands: ReadonlyArray): SlashCommandState[] { return commands .map(normalizeSlashCommand) .filter((command): command is SlashCommandState => command !== null) } function mergeSlashCommandUpdates( current: ReadonlyArray, incoming: ReadonlyArray, ): SlashCommandState[] { const merged = new Map() for (const command of current) { if (command.name) merged.set(command.name, command) } for (const command of incoming) { if (command.name) merged.set(command.name, command) } return [...merged.values()] } async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) const restoredNotifications = { ...reconstructAgentNotifications(messages), ...agentNotificationRecordFromList(taskNotifications ?? []), } return { rawMessages: messages, uiMessages, activeGoal: deriveActiveGoalFromMessages(uiMessages), restoredNotifications, restoredBackgroundTasks: backgroundTaskRecordFromNotifications(Object.values(restoredNotifications)), lastTodos: extractLastTodoWriteFromHistory(messages), hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages), } } export const useChatStore = create((set, get) => ({ sessions: {}, getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(), connectToSession: (sessionId) => { void useCLITaskStore.getState().fetchSessionTasks(sessionId) const existing = get().sessions[sessionId] if (existing && existing.connectionState !== 'disconnected') return set((s) => ({ sessions: { ...s.sessions, [sessionId]: { ...createDefaultSessionState(), connectionState: 'connecting', messages: existing?.messages ?? [], activeGoal: existing?.activeGoal ?? null, composerDraft: existing?.composerDraft ?? null, }, }, })) wsManager.clearHandlers(sessionId) wsManager.connect(sessionId) wsManager.onMessage(sessionId, (msg) => { if (msg.type === 'connected') { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) })) } get().handleServerMessage(sessionId, msg) }) const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId] if (runtimeSelection) { wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection }) } if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) { wsManager.send(sessionId, { type: 'prewarm_session' }) } get().loadHistory(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { if (get().sessions[sessionId]) { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) })) } }) .catch(() => { if (get().sessions[sessionId]) { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) })) } }) }, disconnectSession: (sessionId) => { const session = get().sessions[sessionId] if (session?.elapsedTimer) clearInterval(session.elapsedTimer) if (pendingDeltaBySession.has(sessionId)) { const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) wsManager.disconnect(sessionId) set((s) => { const { [sessionId]: _, ...rest } = s.sessions return { sessions: rest } }) }, sendMessage: (sessionId, content, attachments, options) => { const userFacingContent = options?.displayContent?.trim() || content.trim() const modelFacingContent = buildModelContent(content, attachments) const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId) const visibleAttachments = options?.displayAttachments ?? attachments const uiAttachments: UIAttachment[] | undefined = visibleAttachments && visibleAttachments.length > 0 ? visibleAttachments.map((a) => ({ type: a.type, name: a.name || a.path || a.mimeType || a.type, path: a.path, data: a.data, mimeType: a.mimeType, lineStart: a.lineStart, lineEnd: a.lineEnd, note: a.note, quote: a.quote, })) : undefined const taskStore = useCLITaskStore.getState() const sessionTasks = taskStore.sessionId === sessionId ? taskStore.tasks : [] const allTasksDone = sessionTasks.length > 0 && sessionTasks.every((t) => t.status === 'completed') const completedTaskSummary = allTasksDone ? sessionTasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })) : [] if (!isMemberSession && allTasksDone) { void taskStore.resetCompletedTasks(sessionId) } if (!isMemberSession) { updateOptimisticSessionTitle(sessionId, userFacingContent) } set((s) => { const session = s.sessions[sessionId] ?? createDefaultSessionState() const bufferedDelta = consumePendingDelta(sessionId) const pendingAssistantText = `${session.streamingText}${bufferedDelta}` const newMessages = pendingAssistantText.trim() ? appendAssistantTextMessage(session.messages, pendingAssistantText, Date.now()) : [...session.messages] if (!isMemberSession && allTasksDone) { newMessages.push({ id: nextId(), type: 'task_summary', tasks: completedTaskSummary, timestamp: Date.now(), }) } newMessages.push({ id: nextId(), type: 'user_text', content: userFacingContent, ...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}), attachments: isMemberSession ? undefined : uiAttachments, timestamp: Date.now(), ...(isMemberSession ? { pending: true } : {}), }) if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer) const timer = !isMemberSession ? setInterval(() => { set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) }, 1000) : null return { sessions: { ...s.sessions, [sessionId]: { ...session, messages: newMessages, chatState: 'thinking', elapsedSeconds: 0, streamingText: '', statusVerb: isMemberSession ? '' : randomSpinnerVerb(), apiRetry: null, elapsedTimer: timer, connectionState: isMemberSession ? 'connected' : session.connectionState, }, }, } }) if (isMemberSession) { void useTeamStore.getState().sendMessageToMember(sessionId, userFacingContent) .catch((err) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (session) => ({ chatState: 'idle', messages: [ ...session.messages, { id: nextId(), type: 'error', message: err instanceof Error ? err.message : String(err), code: 'TEAM_MEMBER_MESSAGE_FAILED', timestamp: Date.now(), }, ], })), })) }) return } wsManager.send(sessionId, { type: 'user_message', content, attachments }) }, respondToPermission: (sessionId, requestId, allowed, options) => { wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(options?.rule ? { rule: options.rule } : {}), ...(options?.updatedInput ? { updatedInput: options.updatedInput } : {}), }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) })) }, respondToComputerUsePermission: (sessionId, requestId, response) => { wsManager.send(sessionId, { type: 'computer_use_permission_response', requestId, response, }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingComputerUsePermission: null, chatState: response.userConsented === false ? 'idle' : 'tool_executing', })), })) }, setSessionRuntime: (sessionId, selection) => { wsManager.send(sessionId, { type: 'set_runtime_config', ...selection, }) }, setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return wsManager.send(sessionId, { type: 'set_permission_mode', mode }) }, stopGeneration: (sessionId) => { wsManager.send(sessionId, { type: 'stop_generation' }) if (pendingDeltaBySession.has(sessionId)) { const text = consumePendingDelta(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } set((s) => { const session = s.sessions[sessionId] if (!session) return s if (session.elapsedTimer) clearInterval(session.elapsedTimer) return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', pendingPermission: null, pendingComputerUsePermission: null, apiRetry: null, elapsedTimer: null, }, }, } }) }, loadHistory: async (sessionId) => { try { const { uiMessages, activeGoal, restoredNotifications, restoredBackgroundTasks, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session) return state if (session.messages.length > 0) { return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ activeGoal: activeGoal ?? s.activeGoal ?? null, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, backgroundAgentTasks: mergeBackgroundAgentTaskRecords( s.backgroundAgentTasks ?? {}, restoredBackgroundTasks, ), messages: mergeRestoredHistoryIntoLiveMessages( mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks), uiMessages, ), })) } } return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ messages: mergeBackgroundTaskMessages(uiMessages, restoredBackgroundTasks), activeGoal, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, backgroundAgentTasks: mergeBackgroundAgentTaskRecords( s.backgroundAgentTasks ?? {}, restoredBackgroundTasks, ), })) } }) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() if (taskStore.sessionId === sessionId && taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos, sessionId) } else { useCLITaskStore.getState().setTasksFromTodos([], sessionId) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed(sessionId) } } catch { // Session may not have messages yet } }, reloadHistory: async (sessionId) => { try { const { uiMessages, activeGoal, restoredNotifications, restoredBackgroundTasks, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session) return state if (session.elapsedTimer) clearInterval(session.elapsedTimer) return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: mergeBackgroundTaskMessages(uiMessages, restoredBackgroundTasks), activeGoal, agentTaskNotifications: restoredNotifications, backgroundAgentTasks: restoredBackgroundTasks, chatState: 'idle', activeThinkingId: null, activeToolUseId: null, activeToolName: null, streamingText: '', streamingToolInput: '', pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, statusVerb: '', apiRetry: null, })), } }) if (lastTodos && lastTodos.length > 0) { useCLITaskStore.getState().setTasksFromTodos(lastTodos, sessionId) } else { useCLITaskStore.getState().setTasksFromTodos([], sessionId) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed(sessionId) } } catch { // Session may not have messages yet } }, queueComposerPrefill: (sessionId, prefill) => { set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ composerPrefill: { text: prefill.text, attachments: prefill.attachments, nonce: Date.now(), }, })), })) }, setComposerDraft: (sessionId, draft) => { set((state) => { const session = state.sessions[sessionId] ?? createDefaultSessionState() return { sessions: { ...state.sessions, [sessionId]: { ...session, composerDraft: draft, }, }, } }) }, clearComposerDraft: (sessionId) => { set((state) => ({ sessions: updateSessionIn(state.sessions, sessionId, () => ({ composerDraft: null, })), })) }, clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle', apiRetry: null, })) })) }, handleServerMessage: (sessionId, msg) => { const update = (updater: (session: PerSessionState) => Partial) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) })) } switch (msg.type) { case 'connected': break case 'status': update((session) => { const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}` const hasPendingStreamText = session.chatState === 'streaming' && pendingText.trim().length > 0 // Background task progress can arrive while the assistant is still // streaming one markdown reply. Keep that turn intact so we do not // split formatting markers (for example backticks/strong markers) // across separate bubbles. const preserveStreamingTurn = hasPendingStreamText && msg.state !== 'idle' && msg.state !== 'compacting' const shouldFlush = hasPendingStreamText && (msg.state === 'idle' || msg.state === 'compacting') let nextMessages = session.messages if (shouldFlush) { nextMessages = appendAssistantTextMessage(nextMessages, pendingText, Date.now()) } if (msg.state === 'compacting') { nextMessages = appendOrUpdateTailCompactSummary( nextMessages, { title: 'Context compacted', phase: 'compacting', }, Date.now(), ) } else { nextMessages = dropTailCompactingCompactSummary(nextMessages) } return { chatState: preserveStreamingTurn ? 'streaming' : msg.state, statusVerb: msg.state === 'idle' ? '' : msg.verb && msg.verb !== 'Thinking' ? msg.verb : '', ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null } : {}), ...(msg.state === 'idle' ? { apiRetry: null } : {}), ...(nextMessages !== session.messages ? { messages: nextMessages } : {}), ...(shouldFlush ? { streamingText: '', } : pendingText !== session.streamingText ? { streamingText: pendingText } : {}), } }) if (msg.state === 'idle') { const session = get().sessions[sessionId] if (session?.elapsedTimer) { clearInterval(session.elapsedTimer) update(() => ({ elapsedTimer: null })) } } // Sync tab status useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running') break case 'content_start': { const session = get().sessions[sessionId] if (!session) break const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}` if (msg.blockType !== 'text' && pendingText.trim()) { update((s) => ({ messages: appendAssistantTextMessage(s.messages, pendingText, Date.now()), streamingText: '', })) } if (msg.blockType === 'text') { update((s) => ({ ...(pendingText !== s.streamingText ? { streamingText: pendingText } : {}), chatState: 'streaming', activeThinkingId: null, apiRetry: null, })) } else if (msg.blockType === 'tool_use') { rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId) const toolUseId = msg.toolUseId ?? null const toolName = msg.toolName ?? 'unknown' update((s) => ({ ...(toolUseId ? { messages: upsertToolUseMessage(s.messages, toolUseId, (existing) => ({ id: existing?.id ?? nextId(), type: 'tool_use', toolName, toolUseId, input: existing?.input ?? {}, timestamp: existing?.timestamp ?? Date.now(), parentToolUseId: msg.parentToolUseId ?? existing?.parentToolUseId, isPending: true, partialInput: existing?.partialInput ?? '', })), } : {}), activeToolUseId: toolUseId, activeToolName: toolName, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, apiRetry: null, })) } break } case 'api_retry': { const attempt = Math.max(1, Math.trunc(msg.attempt)) const maxRetries = Math.max(attempt, Math.trunc(msg.maxRetries)) const retryDelayMs = Math.max(0, Math.trunc(msg.retryDelayMs)) update((session) => ({ apiRetry: { attempt, maxRetries, retryDelayMs, errorStatus: msg.errorStatus ?? null, errorType: msg.errorType, errorMessage: msg.errorMessage, receivedAt: Date.now(), }, chatState: session.chatState === 'idle' ? 'thinking' : session.chatState, activeThinkingId: null, statusVerb: '', })) useTabStore.getState().updateTabStatus(sessionId, 'running') break } case 'content_delta': if (msg.text !== undefined) { if (!get().sessions[sessionId]) break appendPendingDelta(sessionId, msg.text) if (!flushTimerBySession.has(sessionId)) { const timer = setTimeout(() => { const text = pendingDeltaBySession.get(sessionId) ?? '' pendingDeltaBySession.delete(sessionId) flushTimerBySession.delete(sessionId) update((s) => ({ streamingText: s.streamingText + text })) }, 50) flushTimerBySession.set(sessionId, timer) } } if (msg.toolInput !== undefined) { update((s) => { const partialInput = s.streamingToolInput + msg.toolInput const activeToolUseId = s.activeToolUseId return { streamingToolInput: partialInput, ...(activeToolUseId ? { messages: upsertToolUseMessage(s.messages, activeToolUseId, (existing) => { const toolName = existing?.toolName ?? s.activeToolName ?? 'unknown' return { id: existing?.id ?? nextId(), type: 'tool_use', toolName, toolUseId: activeToolUseId, input: buildPartialToolInputPreview(partialInput, existing?.input), timestamp: existing?.timestamp ?? Date.now(), parentToolUseId: existing?.parentToolUseId ?? getPendingToolParentUseId(sessionId, activeToolUseId), isPending: true, partialInput, } }), } : {}), } }) } break case 'thinking': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}` const base = pendingText.trim() ? appendAssistantTextMessage(s.messages, pendingText, Date.now()) : s.messages const last = base[base.length - 1] if (last && last.type === 'thinking') { const updated = [...base] updated[updated.length - 1] = { ...last, content: last.content + msg.text } return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } } const id = nextId() return { messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], chatState: 'thinking', activeThinkingId: id, streamingText: '', } }) break case 'tool_use_complete': { const session = get().sessions[sessionId] const toolName = msg.toolName || session?.activeToolName || 'unknown' const toolUseId = msg.toolUseId || session?.activeToolUseId || '' const parentToolUseId = msg.parentToolUseId ?? getPendingToolParentUseId(sessionId, toolUseId) rememberPendingToolParentUseId(sessionId, toolUseId, parentToolUseId) update((s) => ({ messages: toolUseId ? upsertToolUseMessage(s.messages, toolUseId, (existing) => ({ id: existing?.id ?? nextId(), type: 'tool_use', toolName, toolUseId, input: msg.input, timestamp: existing?.timestamp ?? Date.now(), parentToolUseId, isPending: false, })) : [...s.messages, { id: nextId(), type: 'tool_use', toolName, toolUseId, input: msg.input, timestamp: Date.now(), parentToolUseId, isPending: false, }], activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '', })) if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos, sessionId) } else if (TASK_TOOL_NAMES.has(toolName)) { const useId = msg.toolUseId || session?.activeToolUseId if (useId) addPendingTaskToolUseId(sessionId, useId) } break } case 'tool_result': { const now = Date.now() const pendingParentToolUseId = consumePendingToolParentUseId(sessionId, msg.toolUseId) const parentToolUseId = msg.parentToolUseId ?? pendingParentToolUseId update((s) => { let messages: UIMessage[] = [...s.messages, { id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId, content: msg.content, isError: msg.isError, timestamp: now, parentToolUseId, }] let backgroundAgentTasks = s.backgroundAgentTasks ?? {} const stoppedTask = msg.isError ? null : getStoppedBackgroundTaskFromToolResult(s.messages, msg.toolUseId, msg.content) if (stoppedTask) { backgroundAgentTasks = upsertBackgroundAgentTask(backgroundAgentTasks, stoppedTask, now) const task = backgroundAgentTasks[stoppedTask.taskId] if (task) { messages = upsertBackgroundTaskMessage(messages, task, now) } } return { messages, ...(stoppedTask ? { backgroundAgentTasks } : {}), chatState: 'thinking', activeThinkingId: null, } }) if (consumePendingTaskToolUseId(sessionId, msg.toolUseId)) { useCLITaskStore.getState().refreshTasks(sessionId) } break } case 'permission_request': notifyDesktop({ dedupeKey: `permission:${msg.requestId}`, cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` : '有一个工具请求正在等待允许。', target: { type: 'session', sessionId }, }) update((s) => ({ pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, toolUseId: msg.toolUseId, input: msg.input, description: msg.description, }, pendingComputerUsePermission: null, chatState: 'permission_pending', activeThinkingId: null, apiRetry: null, messages: msg.toolName === 'AskUserQuestion' ? s.messages : [...s.messages, { id: nextId(), type: 'permission_request', requestId: msg.requestId, toolName: msg.toolName, toolUseId: msg.toolUseId, input: msg.input, description: msg.description, timestamp: Date.now(), }], })) break case 'computer_use_permission_request': notifyDesktop({ dedupeKey: `computer-use-permission:${msg.requestId}`, cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.request.reason || 'Computer Use 正在等待允许。', target: { type: 'session', sessionId }, }) update(() => ({ pendingComputerUsePermission: { requestId: msg.requestId, request: msg.request, }, pendingPermission: null, chatState: 'permission_pending', activeThinkingId: null, apiRetry: null, })) break case 'message_complete': { const session = get().sessions[sessionId] if (!session) break const wasAgentRunning = session.chatState !== 'idle' const text = `${session.streamingText}${consumePendingDelta(sessionId)}` let completionMessages = session.messages if (text.trim()) { completionMessages = appendAssistantTextMessage(session.messages, text, Date.now()) update(() => ({ messages: completionMessages, streamingText: '', })) } else if (text !== session.streamingText) { update(() => ({ streamingText: text })) } if (session.elapsedTimer) clearInterval(session.elapsedTimer) update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, apiRetry: null, })) useTabStore.getState().updateTabStatus(sessionId, 'idle') const notification = wasAgentRunning ? buildAgentCompletionNotification(sessionId, completionMessages, text) : null if (notification) { void notifyDesktop({ dedupeKey: notification.dedupeKey, cooldownScope: 'agent-completion', title: notification.title, body: notification.body, target: { type: 'session', sessionId }, }) } refreshCompletedTranscriptHistory(get, sessionId) break } case 'error': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}` let newMessages = s.messages if (pendingText.trim()) { newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now()) } newMessages = dropTailCompactingCompactSummary(newMessages) newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }] return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '', statusVerb: '', pendingPermission: null, pendingComputerUsePermission: null, apiRetry: null, } }) useTabStore.getState().updateTabStatus(sessionId, 'error') { const session = get().sessions[sessionId] if (session?.elapsedTimer) { clearInterval(session.elapsedTimer) update(() => ({ elapsedTimer: null })) } } break case 'team_created': useTeamStore.getState().handleTeamCreated(msg.teamName) break case 'team_update': useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) break case 'team_deleted': useTeamStore.getState().handleTeamDeleted(msg.teamName) break case 'task_update': break case 'session_title_updated': useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) useTabStore.getState().updateTabTitle(msg.sessionId, msg.title) break case 'system_notification': if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { const incomingCommands = normalizeSlashCommandList(msg.data) update((session) => ({ slashCommands: mergeSlashCommandUpdates(session.slashCommands, incomingCommands), })) void sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { if (!get().sessions[sessionId]) return set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: normalizeSlashCommandList(commands), })), })) }) .catch(() => { // Keep the last known local + CLI union when the authoritative refresh is unavailable. }) } if (msg.subtype === 'session_cleared') { const session = get().sessions[sessionId] if (session?.elapsedTimer) clearInterval(session.elapsedTimer) update(() => ({ messages: [], streamingText: '', streamingToolInput: '', activeToolUseId: null, activeToolName: null, activeThinkingId: null, pendingPermission: null, pendingComputerUsePermission: null, chatState: 'idle', elapsedTimer: null, elapsedSeconds: 0, statusVerb: '', apiRetry: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], activeGoal: null, backgroundAgentTasks: {}, agentTaskNotifications: {}, })) clearPendingDelta(sessionId) clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) useCLITaskStore.getState().clearTasks(sessionId) useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') useTabStore.getState().updateTabTitle(sessionId, 'New Session') useTabStore.getState().updateTabStatus(sessionId, 'idle') } if (msg.subtype === 'compact_boundary') { const metadata = compactMetadataFromUnknown(msg.data) update((session) => ({ chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState, statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb, messages: appendOrUpdateTailCompactSummary( session.messages, { title: typeof msg.message === 'string' && msg.message.trim() ? msg.message : 'Context compacted', phase: 'complete', ...metadata, }, Date.now(), ), })) } if (msg.subtype === 'compact_summary') { const summary = extractCompactSummaryContent(msg.message) if (summary) { update((session) => ({ messages: appendOrUpdateTailCompactSummary( session.messages, { title: 'Context compacted', phase: 'complete', summary, ...compactMetadataFromUnknown(msg.data), }, Date.now(), ), })) } } if (msg.subtype === 'memory_saved') { const files = normalizeMemoryEventFiles(msg.data) if (files.length > 0) { update((session) => ({ messages: [ ...session.messages, { id: nextId(), type: 'memory_event', event: 'saved', files, message: msg.message, teamCount: normalizeMemoryTeamCount(msg.data), timestamp: Date.now(), }, ], })) } } if (msg.subtype === 'goal_event') { const goalEvent = normalizeGoalEventData(msg.data, msg.message) if (goalEvent) { update((session) => ({ activeGoal: applyGoalEventToActiveGoal(session.activeGoal ?? null, goalEvent, Date.now()), messages: [ ...session.messages, { id: nextId(), type: 'goal_event', ...goalEvent, timestamp: Date.now(), }, ], })) } } if ((msg.subtype === 'task_started' || msg.subtype === 'task_progress') && msg.data && typeof msg.data === 'object') { const taskEvent = normalizeBackgroundAgentTaskEvent(msg.data, msg.subtype) if (taskEvent) { const now = Date.now() update((session) => { const backgroundAgentTasks = upsertBackgroundAgentTask( session.backgroundAgentTasks ?? {}, taskEvent, now, ) const task = backgroundAgentTasks[taskEvent.taskId] return { backgroundAgentTasks, ...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}), } }) } } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record const taskEvent = normalizeBackgroundAgentTaskEvent(data, 'task_notification') const toolUseId = typeof data.tool_use_id === 'string' && data.tool_use_id.trim() ? data.tool_use_id : null const taskResult = readNonEmptyString(data, 'result') const taskStatus = data.status if (taskEvent) { const now = Date.now() update((session) => { const backgroundAgentTasks = upsertBackgroundAgentTask( session.backgroundAgentTasks ?? {}, taskEvent, now, ) const task = backgroundAgentTasks[taskEvent.taskId] return { backgroundAgentTasks, ...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}), agentTaskNotifications: { ...session.agentTaskNotifications, ...(toolUseId && (taskStatus === 'completed' || taskStatus === 'failed' || taskStatus === 'stopped') ? { [toolUseId]: { taskId: taskEvent.taskId, toolUseId, status: taskStatus, summary: taskEvent.summary, result: taskResult, outputFile: taskEvent.outputFile, usage: taskEvent.usage, }, } : {}), }, } }) } } break case 'pong': break } }, })) function updateOptimisticSessionTitle(sessionId: string, content: string): void { const title = deriveSessionTitle(content) if (!title) return const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId) if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return useSessionStore.getState().updateSessionTitle(sessionId, title) useTabStore.getState().updateTabTitle(sessionId, title) } // ─── History mapping helpers (unchanged from original) ───────── type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i const GOAL_EVENT_ACTIONS = new Set([ 'created', 'replaced', 'status', 'paused', 'resumed', 'completed', 'cleared', 'message', ]) /** * Check if text is a teammate-message (internal agent-to-agent communication). * Uses full open+close tag match to avoid false positives on user text * that merely mentions the tag name (e.g., pasting code or discussing the protocol). */ function isTeammateMessage(text: string): boolean { return text.includes('') } function extractHistoryTextBlocks(content: unknown): string[] { if (typeof content === 'string') return [content] if (!Array.isArray(content)) return [] return content .flatMap((block) => { if (!block || typeof block !== 'object') return [] const record = block as Record return record.type === 'text' && typeof record.text === 'string' ? [record.text] : [] }) .map((text) => text.trim()) .filter(Boolean) } function isTaskNotificationContent(content: unknown): boolean { const textBlocks = extractHistoryTextBlocks(content) return textBlocks.length > 0 && textBlocks.every((text) => extractTaskNotificationXml(text) !== null) } function extractTaskNotificationXml(text: string): string | null { const trimmed = text.trim() if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed return trimmed.match(/\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null } function decodeXmlText(text: string): string { return text .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') } function readXmlTag(xml: string, tag: string): string | undefined { const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')) return match?.[1] ? decodeXmlText(match[1].trim()) : undefined } function readNonEmptyString(record: Record, ...keys: string[]): string | undefined { for (const key of keys) { const value = record[key] if (typeof value === 'string' && value.trim()) return value.trim() } return undefined } function readRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null return value as Record } function normalizeHistoryToolResultContent(content: unknown, toolUseResult: unknown): unknown { const result = readRecord(toolUseResult) const answers = readRecord(result?.answers) if (!result || !answers || !Array.isArray(result.questions)) return content return { questions: result.questions, answers, } } function parseJsonRecord(value: unknown): Record | null { const record = readRecord(value) if (record) return record if (typeof value !== 'string') return null const trimmed = value.trim() if (!trimmed.startsWith('{')) return null try { return readRecord(JSON.parse(trimmed)) } catch { return null } } function findToolUseMessage(messages: UIMessage[], toolUseId: string): ToolCall | null { return messages.find(( message, ): message is ToolCall => message.type === 'tool_use' && message.toolUseId === toolUseId) ?? null } function getStoppedBackgroundTaskFromToolResult( messages: UIMessage[], toolUseId: string, content: unknown, ): (Partial & Pick) | null { const toolUse = findToolUseMessage(messages, toolUseId) if (!toolUse || !TASK_STOP_TOOL_NAMES.has(toolUse.toolName)) return null const input = readRecord(toolUse.input) ?? {} const output = parseJsonRecord(content) ?? {} const taskId = readNonEmptyString(output, 'task_id', 'taskId') ?? readNonEmptyString(input, 'task_id', 'taskId', 'shell_id', 'shellId') if (!taskId) return null return { taskId, status: 'stopped', taskType: readNonEmptyString(output, 'task_type', 'taskType'), description: readNonEmptyString(output, 'command', 'description', 'message'), summary: readNonEmptyString(output, 'message'), } } function normalizeBackgroundTaskUsage(value: unknown): BackgroundAgentTaskUsage | undefined { if (!value || typeof value !== 'object') return undefined const record = value as Record const usage: BackgroundAgentTaskUsage = {} const totalTokens = record.total_tokens ?? record.totalTokens const toolUses = record.tool_uses ?? record.toolUses const durationMs = record.duration_ms ?? record.durationMs if (typeof totalTokens === 'number') usage.totalTokens = totalTokens if (typeof toolUses === 'number') usage.toolUses = toolUses if (typeof durationMs === 'number') usage.durationMs = durationMs return Object.keys(usage).length > 0 ? usage : undefined } function normalizeBackgroundAgentTaskEvent( data: unknown, subtype: string, ): Partial & Pick | null { if (!data || typeof data !== 'object') return null const record = data as Record const taskId = readNonEmptyString(record, 'task_id', 'taskId') const toolUseId = readNonEmptyString(record, 'tool_use_id', 'toolUseId') const id = taskId ?? toolUseId if (!id) return null const rawStatus = readNonEmptyString(record, 'status') const status = rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' ? rawStatus : rawStatus === 'killed' ? 'stopped' : subtype === 'task_notification' ? 'completed' : 'running' return { taskId: id, toolUseId, status, description: readNonEmptyString(record, 'description', 'message', 'title'), taskType: readNonEmptyString(record, 'task_type', 'taskType'), workflowName: readNonEmptyString(record, 'workflow_name', 'workflowName'), prompt: readNonEmptyString(record, 'prompt'), summary: readNonEmptyString(record, 'summary'), lastToolName: readNonEmptyString(record, 'last_tool_name', 'lastToolName'), outputFile: readNonEmptyString(record, 'output_file', 'outputFile'), usage: normalizeBackgroundTaskUsage(record.usage), } } function upsertBackgroundAgentTask( current: Record, event: Partial & Pick, now: number, ): Record { const existingKey = current[event.taskId] ? event.taskId : event.toolUseId ? Object.keys(current).find((key) => key === event.toolUseId || current[key]?.toolUseId === event.toolUseId) : undefined const existing = existingKey ? current[existingKey] : undefined const next = { ...current } if (existingKey && existingKey !== event.taskId) { delete next[existingKey] } return { ...next, [event.taskId]: { taskId: event.taskId, toolUseId: event.toolUseId ?? existing?.toolUseId, status: event.status, description: event.description ?? existing?.description, taskType: event.taskType ?? existing?.taskType, workflowName: event.workflowName ?? existing?.workflowName, prompt: event.prompt ?? existing?.prompt, summary: event.summary ?? existing?.summary, lastToolName: event.lastToolName ?? existing?.lastToolName, outputFile: event.outputFile ?? existing?.outputFile, usage: event.usage ?? existing?.usage, startedAt: existing?.startedAt ?? now, updatedAt: now, }, } } function normalizeGoalEventData( data: unknown, fallbackMessage?: string, ): Omit, 'id' | 'type' | 'timestamp'> | null { if (!data || typeof data !== 'object') { const message = typeof fallbackMessage === 'string' ? fallbackMessage.trim() : '' return message ? { action: 'message', message } : null } const record = data as Record const action = typeof record.action === 'string' && GOAL_EVENT_ACTIONS.has(record.action as GoalEventAction) ? record.action as GoalEventAction : 'message' const read = (key: string) => typeof record[key] === 'string' && record[key].trim() ? record[key].trim() : undefined return { action, status: read('status'), objective: read('objective'), budget: read('budget'), elapsed: read('elapsed'), continuations: read('continuations'), message: read('message') ?? (typeof fallbackMessage === 'string' ? fallbackMessage.trim() : undefined), } } function applyGoalEventToActiveGoal( current: ActiveGoalState | null, event: Omit, 'id' | 'type' | 'timestamp'>, updatedAt: number, ): ActiveGoalState | null { if (event.action === 'cleared') return null if ( event.action === 'message' && event.message && /no (active )?goal/i.test(event.message) ) { return current } if (event.action === 'message') return current const baseGoal = event.action === 'created' || event.action === 'replaced' ? null : current return { action: event.action, status: event.status ?? (event.action === 'completed' ? 'complete' : baseGoal?.status), objective: event.objective ?? baseGoal?.objective, budget: event.budget ?? baseGoal?.budget, elapsed: event.elapsed ?? baseGoal?.elapsed, continuations: event.continuations ?? baseGoal?.continuations, message: event.message ?? baseGoal?.message, updatedAt, } } function deriveActiveGoalFromMessages(messages: UIMessage[]): ActiveGoalState | null { return messages.reduce((activeGoal, message) => { if (message.type !== 'goal_event') return activeGoal return applyGoalEventToActiveGoal(activeGoal, message, message.timestamp) }, null) } function extractLocalCommandText(content: unknown): string | null { if (typeof content !== 'string') return null return content.trim() || null } function parseGoalCommandFromLocalCommand(content: unknown): { name: string; args: string } | null { const text = extractLocalCommandText(content) if (!text) return null const commandName = readXmlTag(text, 'command-name') if (!commandName) return null return { name: commandName.replace(/^\//, ''), args: readXmlTag(text, 'command-args') ?? '', } } function formatVisibleLocalCommand(command: { name: string; args: string }): string { const normalizedName = command.name.replace(/^\//, '') const args = command.args.trim() return `/${normalizedName}${args ? ` ${args}` : ''}` } function extractLocalCommandOutputText(content: unknown): string | null { const text = extractLocalCommandText(content) if (!text) return null return readXmlTag(text, 'local-command-stdout') ?? readXmlTag(text, 'local-command-stderr') ?? null } function isCompactLocalCommandOutput(output: string): boolean { return output.trim() === 'Compacted' } function parseGoalEventFromLocalCommandOutput( output: string, command: { name: string; args: string } | null, ): Omit, 'id' | 'type' | 'timestamp'> | null { if (command && command.name !== 'goal') return null const trimmed = output.trim() if (!trimmed) return null if (trimmed === 'Goal cleared.' || trimmed.startsWith('Goal cleared:')) return { action: 'cleared', message: trimmed } if (trimmed === 'Goal marked complete.') return { action: 'completed', message: trimmed } if (trimmed === 'No active goal.') return { action: 'message', message: trimmed } if (trimmed.startsWith('Goal set:')) { const objective = trimmed.slice('Goal set:'.length).trim() return { action: 'created', status: 'active', objective: objective || undefined, message: trimmed, } } return command?.name === 'goal' ? { action: 'message', message: trimmed } : null } function extractTaskNotification(content: unknown): AgentTaskNotification | null { const xml = extractHistoryTextBlocks(content) .map((text) => extractTaskNotificationXml(text)) .find((value): value is string => value !== null) if (!xml) return null const toolUseId = readXmlTag(xml, 'tool-use-id') const status = readXmlTag(xml, 'status') if ( !toolUseId || (status !== 'completed' && status !== 'failed' && status !== 'stopped') ) { return null } const taskId = readXmlTag(xml, 'task-id') || toolUseId const summary = readXmlTag(xml, 'summary') const result = readXmlTag(xml, 'result') const outputFile = readXmlTag(xml, 'output-file') return { taskId, toolUseId, status, ...(summary ? { summary } : {}), ...(result ? { result } : {}), ...(outputFile ? { outputFile } : {}), } } function agentNotificationRecordFromList( notifications: AgentTaskNotification[], ): Record { return Object.fromEntries( notifications.map((notification) => [notification.toolUseId, notification]), ) } function backgroundTaskRecordFromNotifications( notifications: AgentTaskNotification[], ): Record { return notifications.reduce>((tasks, notification) => { const parsedTimestamp = notification.timestamp ? new Date(notification.timestamp).getTime() : NaN const now = Number.isFinite(parsedTimestamp) ? parsedTimestamp : Date.now() return upsertBackgroundAgentTask(tasks, { taskId: notification.taskId, toolUseId: notification.toolUseId, status: notification.status, summary: notification.summary, outputFile: notification.outputFile, usage: notification.usage, }, now) }, {}) } function mergeBackgroundAgentTaskRecords( current: Record, restored: Record, ): Record { return Object.values(restored).reduce( (tasks, task) => upsertBackgroundAgentTask(tasks, task, task.updatedAt), current, ) } const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g function extractVisibleTeammateMessageContents(text: string): string[] { const contents: string[] = [] for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) { const content = match[2]?.trim() if (!content) continue if (content.startsWith('{') && content.endsWith('}')) { try { const parsed = JSON.parse(content) as Record if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) { continue } } catch { // Keep non-JSON payloads that happen to look like JSON. } } contents.push(content) } return contents } function pushAssistantHistoryText( messages: UIMessage[], content: string, timestamp: number, model?: string, transcriptMessageId?: string, ): void { if (!content.trim()) return const last = messages[messages.length - 1] const canMergeIntoLast = last?.type === 'assistant_text' && ( transcriptMessageId ? last.transcriptMessageId === transcriptMessageId : !last.transcriptMessageId ) if (canMergeIntoLast) { last.content += content if (model && !last.model) last.model = model if (transcriptMessageId && !last.transcriptMessageId) { last.transcriptMessageId = transcriptMessageId } return } messages.push({ id: nextId(), type: 'assistant_text', content, timestamp, ...(transcriptMessageId ? { transcriptMessageId } : {}), ...(model ? { model } : {}), }) } type HistoryMappingOptions = { includeTeammateMessages?: boolean } function buildModelContent(content: string, attachments?: AttachmentRef[]): string { const paths = attachments ?.map((attachment) => attachment.path) .filter((path): path is string => typeof path === 'string' && path.length > 0) ?? [] const trimmed = content.trim() if (paths.length === 0) return trimmed const prefix = paths.map((path) => `@"${path}"`).join(' ') return `${prefix} ${trimmed || 'Please analyze the attached files.'}`.trim() } function getReferenceName(referencePath: string): string { const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '') const name = normalized.split('/').filter(Boolean).pop() return name || referencePath } function extractLeadingFileReferences(text: string): { content: string attachments?: UIAttachment[] modelContent?: string } { const attachments: UIAttachment[] = [] let remaining = text while (true) { const match = remaining.match(/^@"([^"]+)"\s*/) if (!match?.[1]) break attachments.push({ type: 'file', name: getReferenceName(match[1]), path: match[1], }) remaining = remaining.slice(match[0].length) } if (attachments.length === 0) { return { content: text } } return { content: remaining.trimStart(), attachments, modelContent: text, } } /** * Reconstruct agentTaskNotifications from history. * * During a live session, background agents report completion via system_notification * events (task_notification). These are NOT persisted in JSONL history. On reload, * we reconstruct them by correlating Agent tool_use names with * teammate_ids found in subsequent user messages. */ export function reconstructAgentNotifications(messages: MessageEntry[]): Record { const taskNotifications = messages .filter((message) => message.type === 'user') .map((message) => extractTaskNotification(message.content)) .filter((notification): notification is AgentTaskNotification => notification !== null) // Step 1: Collect Agent tool_use blocks → map agent name to toolUseId const agentNameToToolUseId = new Map() for (const msg of messages) { if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { for (const block of msg.content as AssistantHistoryBlock[]) { if (block.type === 'tool_use' && block.name === 'Agent' && block.id) { const input = block.input as Record | undefined const name = input?.name as string | undefined // Keep first toolUseId per name (consistent with first-wins for teammateContent) if (name && !agentNameToToolUseId.has(name)) agentNameToToolUseId.set(name, block.id) } } } } if (agentNameToToolUseId.size === 0) { return agentNotificationRecordFromList(taskNotifications) } // Step 2: Extract content by teammate_id // Skip lifecycle messages (shutdown_approved, idle_notification, etc.) // which overwrite actual review content if stored later in history const teammateContent = new Map() for (const msg of messages) { if (msg.type !== 'user') continue const text = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? (msg.content as Array<{ type?: string; text?: string }>).filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n') : '' if (!text.includes(' if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) continue } catch { /* not JSON, keep it */ } } // Only store the first meaningful content per teammate (avoid overwrite by later lifecycle msgs) if (!teammateContent.has(match[1])) { teammateContent.set(match[1], content) } } } } // Step 3: Correlate and build notifications const notifications: Record = {} for (const [name, toolUseId] of agentNameToToolUseId) { const content = teammateContent.get(name) if (content) { notifications[toolUseId] = { taskId: toolUseId, toolUseId, status: 'completed', summary: content, } } } for (const notification of taskNotifications) { notifications[notification.toolUseId] = notification } return notifications } export function mapHistoryMessagesToUiMessages( messages: MessageEntry[], options?: HistoryMappingOptions, ): UIMessage[] { const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] let suppressTaskNotificationResponse = false let pendingGoalCommand: { name: string; args: string } | null = null for (const msg of messages) { if (msg.type === 'user' && isTaskNotificationContent(msg.content)) { suppressTaskNotificationResponse = true continue } if (msg.type === 'user') { suppressTaskNotificationResponse = false } else if (suppressTaskNotificationResponse) { continue } const timestamp = new Date(msg.timestamp).getTime() if (msg.type === 'system' && typeof msg.content === 'string') { if (msg.content.trim() === 'Conversation compacted' || msg.content.trim() === 'Context compacted') { const compactMessages = appendOrUpdateTailCompactSummary( uiMessages, { title: 'Context compacted', phase: 'complete' }, timestamp, ) uiMessages.splice(0, uiMessages.length, ...compactMessages) continue } const localCommand = parseGoalCommandFromLocalCommand(msg.content) if (localCommand) { pendingGoalCommand = localCommand if (localCommand.name === 'goal') { uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: formatVisibleLocalCommand(localCommand), timestamp, }) } continue } const localCommandOutput = extractLocalCommandOutputText(msg.content) if (localCommandOutput) { const goalEvent = parseGoalEventFromLocalCommandOutput(localCommandOutput, pendingGoalCommand) pendingGoalCommand = null if (goalEvent) { uiMessages.push({ id: msg.id || nextId(), type: 'goal_event', ...goalEvent, timestamp, }) } continue } } if (msg.type === 'user' && typeof msg.content === 'string') { const localCommandOutput = extractLocalCommandOutputText(msg.content) if (localCommandOutput && isCompactLocalCommandOutput(localCommandOutput)) { continue } const compactSummary = extractCompactSummaryContent(msg.content) if (compactSummary) { const compactMessages = appendOrUpdateTailCompactSummary( uiMessages, { title: 'Context compacted', phase: 'complete', summary: compactSummary, }, timestamp, ) uiMessages.splice(0, uiMessages.length, ...compactMessages) continue } if (isTeammateMessage(msg.content)) { if (!includeTeammateMessages) continue const teammateContents = extractVisibleTeammateMessageContents(msg.content) if (teammateContents.length === 0) continue uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: teammateContents.join('\n\n'), ...(msg.id ? { transcriptMessageId: msg.id } : {}), timestamp, }) continue } const parsed = extractLeadingFileReferences(msg.content) uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(msg.id ? { transcriptMessageId: msg.id } : {}), ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), ...(parsed.attachments ? { attachments: parsed.attachments } : {}), timestamp, }) continue } if (msg.type === 'assistant' && typeof msg.content === 'string') { if (!msg.content.trim()) continue uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, ...(msg.id ? { transcriptMessageId: msg.id } : {}), timestamp, model: msg.model, }) continue } if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { for (const block of msg.content as AssistantHistoryBlock[]) { if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp }) else if (block.type === 'text' && block.text) { pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model, msg.id || undefined) } else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId }) } continue } if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) { const textParts: string[] = [] const attachments: UIAttachment[] = [] for (const block of msg.content as UserHistoryBlock[]) { if (block.type === 'text' && block.text && isTeammateMessage(block.text)) { if (!includeTeammateMessages) continue textParts.push(...extractVisibleTeammateMessageContents(block.text)) } else if (block.type === 'text' && block.text) { textParts.push(block.text) } else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' }) else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: normalizeHistoryToolResultContent(block.content, msg.toolUseResult), isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId, }) } if (textParts.length > 0 || attachments.length > 0) { const parsed = extractLeadingFileReferences(textParts.join('\n')) const allAttachments = [...(parsed.attachments ?? []), ...attachments] uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(msg.id ? { transcriptMessageId: msg.id } : {}), ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), attachments: allAttachments.length > 0 ? allAttachments : undefined, timestamp, }) } } if (msg.type === 'system' && msg.content && typeof msg.content === 'object') { const subtype = (msg.content as { subtype?: unknown }).subtype if (subtype === 'memory_saved') { const files = normalizeMemoryEventFiles(msg.content) if (files.length > 0) { uiMessages.push({ id: msg.id || nextId(), type: 'memory_event', event: 'saved', files, message: typeof (msg.content as { message?: unknown }).message === 'string' ? (msg.content as { message: string }).message : undefined, teamCount: normalizeMemoryTeamCount(msg.content), timestamp, }) } } } } return uiMessages } function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null { let foundIndex = -1 let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { const blocks = msg.content as AssistantHistoryBlock[] for (let j = blocks.length - 1; j >= 0; j--) { const block = blocks[j]! if (block.type === 'tool_use' && block.name === 'TodoWrite') { const input = block.input as { todos?: unknown } | undefined if (input && Array.isArray(input.todos)) { todos = input.todos as Array<{ content: string; status: string; activeForm?: string }> foundIndex = i break } } } if (todos) break } } if (!todos) return null const allDone = todos.every((t) => t.status === 'completed') if (allDone) { for (let i = foundIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user' && messages[i]!.content) return null } } return todos } const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean { let lastTaskIndex = -1 for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]! if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { const blocks = msg.content as AssistantHistoryBlock[] if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break } } } if (lastTaskIndex < 0) return false for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true } return false }