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 { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { RuntimeSelection } from '../types/runtime' import type { AgentTaskNotification, AttachmentRef, ChatState, ComputerUsePermissionRequest, ComputerUsePermissionResponse, UIAttachment, UIMessage, ServerMessage, TokenUsage, } from '../types/chat' type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' 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 slashCommands: Array<{ name: string; description: string }> agentTaskNotifications: Record elapsedTimer: ReturnType | null composerPrefill?: { text: string attachments?: UIAttachment[] nonce: number } | 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: '', slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, composerPrefill: 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 clearMessages: (sessionId: string) => void handleServerMessage: (sessionId: string, msg: ServerMessage) => void } const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const pendingTaskToolUseIds = new Set() let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` // Streaming throttle for content_delta let pendingDelta = '' let flushTimer: ReturnType | null = null function consumePendingDelta(): string { if (flushTimer) { clearTimeout(flushTimer) flushTimer = null } const text = pendingDelta pendingDelta = '' return text } function appendAssistantTextMessage( messages: UIMessage[], content: string, timestamp: number, model?: string, ): UIMessage[] { if (!content.trim()) return messages const last = messages[messages.length - 1] if (last?.type === 'assistant_text') { const merged: UIMessage = { ...last, content: last.content + content, ...(model ?? last.model ? { model: model ?? last.model } : {}), } return [...messages.slice(0, -1), merged] } return [ ...messages, { id: nextId(), type: 'assistant_text', content, timestamp, ...(model ? { model } : {}), }, ] } /** 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) } } } async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) return { rawMessages: messages, uiMessages: mapHistoryMessagesToUiMessages(messages), restoredNotifications: { ...reconstructAgentNotifications(messages), ...agentNotificationRecordFromList(taskNotifications ?? []), }, 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 ?? [], }, }, })) 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 (flushTimer) { clearTimeout(flushTimer); flushTimer = null } if (pendingDelta) { const text = consumePendingDelta() set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) })) } 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 allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') const completedTaskSummary = allTasksDone ? taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })) : [] if (!isMemberSession && allTasksDone) { void taskStore.resetCompletedTasks() } set((s) => { const session = s.sessions[sessionId] ?? createDefaultSessionState() if (flushTimer) { clearTimeout(flushTimer) flushTimer = null } const bufferedDelta = consumePendingDelta() 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(), 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 (flushTimer) { clearTimeout(flushTimer); flushTimer = null } if (pendingDelta) { const text = consumePendingDelta() 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, elapsedTimer: null, }, }, } }) }, loadHistory: async (sessionId) => { try { const { uiMessages, restoredNotifications, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] if (!session || session.messages.length > 0) return state return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ messages: uiMessages, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, })) } }) if (lastTodos && lastTodos.length > 0) { const taskStore = useCLITaskStore.getState() if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos) } else { useCLITaskStore.getState().setTasksFromTodos([]) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed() } } catch { // Session may not have messages yet } }, reloadHistory: async (sessionId) => { try { const { uiMessages, restoredNotifications, 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: uiMessages, agentTaskNotifications: restoredNotifications, chatState: 'idle', activeThinkingId: null, activeToolUseId: null, activeToolName: null, streamingText: '', streamingToolInput: '', pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, statusVerb: '', })), } }) if (lastTodos && lastTodos.length > 0) { useCLITaskStore.getState().setTasksFromTodos(lastTodos) } else { useCLITaskStore.getState().setTasksFromTodos([]) } if (hasMessagesAfterTaskCompletion) { useCLITaskStore.getState().markCompletedAndDismissed() } } 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(), }, })), })) }, clearMessages: (sessionId) => { set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) })) }, 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()}` 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' const shouldFlush = hasPendingStreamText && msg.state === 'idle' return { chatState: preserveStreamingTurn ? 'streaming' : msg.state, ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}), ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}), ...(shouldFlush ? { messages: appendAssistantTextMessage(session.messages, pendingText, Date.now()), 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()}` 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, })) } else if (msg.blockType === 'tool_use') { update(() => ({ activeToolUseId: msg.toolUseId ?? null, activeToolName: msg.toolName ?? null, streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, })) } break } case 'content_delta': if (msg.text !== undefined) { pendingDelta += msg.text if (!flushTimer) { flushTimer = setTimeout(() => { const text = pendingDelta pendingDelta = '' flushTimer = null update((s) => ({ streamingText: s.streamingText + text })) }, 50) } } if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput })) break case 'thinking': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta()}` 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' update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'tool_use', toolName, toolUseId: msg.toolUseId || s.activeToolUseId || '', input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], 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) } else if (TASK_TOOL_NAMES.has(toolName)) { const useId = msg.toolUseId || session?.activeToolUseId if (useId) pendingTaskToolUseIds.add(useId) } break } case 'tool_result': update((s) => ({ messages: [...s.messages, { id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId, content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId, }], chatState: 'thinking', activeThinkingId: null, })) if (pendingTaskToolUseIds.has(msg.toolUseId)) { pendingTaskToolUseIds.delete(msg.toolUseId) useCLITaskStore.getState().refreshTasks() } break case 'permission_request': notifyDesktop({ dedupeKey: `permission:${msg.requestId}`, cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', body: msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` : '有一个工具请求正在等待允许。', }) 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, 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 正在等待允许。', }) update(() => ({ pendingComputerUsePermission: { requestId: msg.requestId, request: msg.request, }, pendingPermission: null, chatState: 'permission_pending', activeThinkingId: null, })) break case 'message_complete': { const session = get().sessions[sessionId] if (!session) break const text = `${session.streamingText}${consumePendingDelta()}` if (text.trim()) { update((s) => ({ messages: appendAssistantTextMessage(s.messages, text, Date.now()), 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, })) break } case 'error': update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta()}` let newMessages = s.messages if (pendingText.trim()) { newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now()) } newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }] return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '', pendingPermission: null, pendingComputerUsePermission: 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)) { update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> })) } 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: '', tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], })) useCLITaskStore.getState().clearTasks() useSessionStore.getState().updateSessionTitle(sessionId, 'New Session') useTabStore.getState().updateTabTitle(sessionId, 'New Session') useTabStore.getState().updateTabStatus(sessionId, 'idle') } if (msg.subtype === 'compact_boundary') { update((session) => ({ messages: [ ...session.messages, { id: nextId(), type: 'system', content: typeof msg.message === 'string' && msg.message.trim() ? msg.message : 'Context compacted', timestamp: Date.now(), }, ], })) } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { const data = msg.data as Record const toolUseId = typeof data.tool_use_id === 'string' && data.tool_use_id.trim() ? data.tool_use_id : null const taskStatus = data.status if ( toolUseId && (taskStatus === 'completed' || taskStatus === 'failed' || taskStatus === 'stopped') ) { update((session) => ({ agentTaskNotifications: { ...session.agentTaskNotifications, [toolUseId]: { taskId: typeof data.task_id === 'string' && data.task_id.trim() ? data.task_id : toolUseId, toolUseId, status: taskStatus, summary: typeof data.summary === 'string' && data.summary.trim() ? data.summary : undefined, outputFile: typeof data.output_file === 'string' && data.output_file.trim() ? data.output_file : undefined, }, }, })) } } break case 'pong': break } }, })) // ─── 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 /** * 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 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 outputFile = readXmlTag(xml, 'output-file') return { taskId, toolUseId, status, ...(summary ? { summary } : {}), ...(outputFile ? { outputFile } : {}), } } function agentNotificationRecordFromList( notifications: AgentTaskNotification[], ): Record { return Object.fromEntries( notifications.map((notification) => [notification.toolUseId, notification]), ) } 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, ): void { if (!content.trim()) return const last = messages[messages.length - 1] if (last?.type === 'assistant_text') { last.content += content if (model && !last.model) last.model = model return } messages.push({ id: nextId(), type: 'assistant_text', content, timestamp, ...(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 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 === 'user' && typeof msg.content === 'string') { 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'), timestamp, }) continue } const parsed = extractLeadingFileReferences(msg.content) uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: parsed.content, ...(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, 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) 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: block.content, 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, ...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}), attachments: allAttachments.length > 0 ? allAttachments : undefined, 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 }