cc-haha/desktop/src/stores/chatStore.ts
程序员阿江(Relakkes) a6d9adbc38 fix: prevent long tool streams from looking stuck
Desktop already receives tool_use start and input deltas before the final tool_use_complete event, but the chat transcript only rendered a visible tool card after the complete event. This makes long Write payloads look frozen while the model is still generating the tool input. The store now upserts a pending tool_use message on stream start, updates a lightweight input preview from deltas, and resolves that same message when the complete event arrives.

Constraint: Long Write calls spend most perceived time streaming JSON tool input, not executing the filesystem write

Rejected: Show only the global active tool label | it does not anchor progress in the transcript where the user is looking

Confidence: high

Scope-risk: moderate

Directive: Keep pending tool_use messages keyed by toolUseId so the complete event updates in place instead of duplicating cards

Tested: bun run check:desktop

Not-tested: Live provider smoke with a real 10k-word Write stream
2026-05-23 17:08:25 +08:00

2549 lines
89 KiB
TypeScript

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<UIMessage, { type: 'tool_use' }>
type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
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<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<string, PerSessionState>
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<string, unknown>
},
) => 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<void>
reloadHistory: (sessionId: string) => Promise<void>
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<string, Set<string>>()
const pendingToolParentUseIdsBySession = new Map<string, Map<string, string>>()
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set<string>()
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<string, string>()
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<string, unknown> {
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<string, unknown> {
const previous = isRecord(previousInput) ? previousInput : {}
const preview: Record<string, unknown> = { ...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<string, string>()
const flushTimerBySession = new Map<string, ReturnType<typeof setTimeout>>()
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<CompactSummaryMessage, 'trigger' | 'preTokens' | 'messagesSummarized'> {
if (!data || typeof data !== 'object') return {}
const record = data as Record<string, unknown>
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<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
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<string, BackgroundAgentTask>,
): 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<BackgroundAgentTask, 'taskType' | 'summary'>): 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<UIMessage, { type: 'goal_event' }> =>
message.type === 'goal_event')
.map((message) => `${message.action}:${message.message ?? ''}:${message.objective ?? ''}`))
const missingTerminalEvents = restoredMessages.filter((
message,
): message is Extract<UIMessage, { type: 'goal_event' }> =>
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<UIMessage, { type: 'user_text' | 'assistant_text' }> =>
(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<string, PerSessionState>,
sessionId: string,
updater: (s: PerSessionState) => Partial<PerSessionState>,
): Record<string, PerSessionState> {
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<unknown>): SlashCommandState[] {
return commands
.map(normalizeSlashCommand)
.filter((command): command is SlashCommandState => command !== null)
}
function mergeSlashCommandUpdates(
current: ReadonlyArray<SlashCommandState>,
incoming: ReadonlyArray<SlashCommandState>,
): SlashCommandState[] {
const merged = new Map<string, SlashCommandState>()
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<ChatStore>((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<PerSessionState>) => {
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<string, unknown>
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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const GOAL_EVENT_ACTIONS = new Set<GoalEventAction>([
'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('<teammate-message') && text.includes('</teammate-message>')
}
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<string, unknown>
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(/<task-notification>\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null
}
function decodeXmlText(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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<string, unknown>, ...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<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
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<string, unknown> | 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<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>) | 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<string, unknown>
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<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'> | null {
if (!data || typeof data !== 'object') return null
const record = data as Record<string, unknown>
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<string, BackgroundAgentTask>,
event: Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
now: number,
): Record<string, BackgroundAgentTask> {
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<Extract<UIMessage, { type: 'goal_event' }>, '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<string, unknown>
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<Extract<UIMessage, { type: 'goal_event' }>, '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<ActiveGoalState | null>((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<Extract<UIMessage, { type: 'goal_event' }>, '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<string, AgentTaskNotification> {
return Object.fromEntries(
notifications.map((notification) => [notification.toolUseId, notification]),
)
}
function backgroundTaskRecordFromNotifications(
notifications: AgentTaskNotification[],
): Record<string, BackgroundAgentTask> {
return notifications.reduce<Record<string, BackgroundAgentTask>>((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<string, BackgroundAgentTask>,
restored: Record<string, BackgroundAgentTask>,
): Record<string, BackgroundAgentTask> {
return Object.values(restored).reduce(
(tasks, task) => upsertBackgroundAgentTask(tasks, task, task.updatedAt),
current,
)
}
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\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<string, unknown>
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-message>
* teammate_ids found in subsequent user messages.
*/
export function reconstructAgentNotifications(messages: MessageEntry[]): Record<string, AgentTaskNotification> {
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<string, string>()
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<string, unknown> | 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 <teammate-message> 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<string, string>()
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('<teammate-message')) continue
for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
if (match[1] && match[2]) {
const content = match[2].trim()
// Skip lifecycle JSON messages (shutdown, idle, terminated notifications)
if (content.startsWith('{') && content.endsWith('}')) {
try {
const parsed = JSON.parse(content) as Record<string, unknown>
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<string, AgentTaskNotification> = {}
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
}