cc-haha/desktop/src/stores/chatStore.ts
程序员阿江(Relakkes) 2a937c6cc7 fix(desktop): prevent reconnects from replaying completed replies
Sleep/wake reconnects can resend persisted assistant text as live output
without transcript ids. Treat that output as replay when it already exists
at the hydrated chat tail, and collapse duplicate transcript-backed text
after history refresh so the UI does not append repeated replies or send
new completion notifications for old turns.

Constraint: Keep the fix inside desktop chat state; do not change the WebSocket protocol or transcript storage shape.
Rejected: Add a macOS sleep/wake listener | it would not address replayed stream events from other reconnect/resume paths.
Confidence: high
Scope-risk: narrow
Directive: Do not remove replay dedupe without testing sleep/wake reconnect output against transcript id hydration.
Tested: bunx vitest run src/stores/chatStore.test.ts --testNamePattern "replay|collapses duplicate"
Tested: bun run test -- --run src/stores/chatStore.test.ts
Tested: bun run check:desktop
Not-tested: Real macOS sleep/wake desktop smoke with native notifications.
2026-05-25 16:09:31 +08:00

2630 lines
92 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>>()
const pendingToolInputDeltaBySession = new Map<string, string>()
const toolInputFlushTimerBySession = 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 consumePendingToolInputDelta(sessionId: string): string {
const flushTimer = toolInputFlushTimerBySession.get(sessionId)
if (flushTimer) {
clearTimeout(flushTimer)
toolInputFlushTimerBySession.delete(sessionId)
}
const text = pendingToolInputDeltaBySession.get(sessionId) ?? ''
pendingToolInputDeltaBySession.delete(sessionId)
return text
}
function appendPendingToolInputDelta(sessionId: string, text: string): void {
pendingToolInputDeltaBySession.set(
sessionId,
`${pendingToolInputDeltaBySession.get(sessionId) ?? ''}${text}`,
)
}
function clearPendingToolInputDelta(sessionId: string): void {
const flushTimer = toolInputFlushTimerBySession.get(sessionId)
if (flushTimer) {
clearTimeout(flushTimer)
toolInputFlushTimerBySession.delete(sessionId)
}
pendingToolInputDeltaBySession.delete(sessionId)
}
function appendAssistantTextMessage(
messages: UIMessage[],
content: string,
timestamp: number,
model?: string,
transcriptMessageId?: string,
): UIMessage[] {
const trimmedContent = content.trim()
if (!trimmedContent) return messages
const last = messages[messages.length - 1]
// Wake/reconnect replay can resend persisted assistant text without a
// transcript id. Ignore chunks that are already present in the hydrated tail.
if (
last?.type === 'assistant_text' &&
last.transcriptMessageId &&
!transcriptMessageId &&
last.content.trim().includes(trimmedContent)
) {
return messages
}
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 dropDuplicateTranscriptTextMessages(messages: UIMessage[]): UIMessage[] {
const seen = new Set<string>()
const deduped: UIMessage[] = []
let changed = false
for (const message of messages) {
if (
(message.type === 'user_text' || message.type === 'assistant_text') &&
message.transcriptMessageId
) {
const key = `${message.type}:${message.transcriptMessageId}:${message.content.trim()}`
if (seen.has(key)) {
changed = true
continue
}
seen.add(key)
}
deduped.push(message)
}
return changed ? deduped : messages
}
function mergeRestoredHistoryIntoLiveMessages(
messages: UIMessage[],
restoredMessages: UIMessage[],
): UIMessage[] {
return mergeRestoredTerminalGoalEvents(
dropDuplicateTranscriptTextMessages(
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 })) }))
}
clearPendingToolInputDelta(sessionId)
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 })) }))
}
clearPendingToolInputDelta(sessionId)
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)
clearPendingToolInputDelta(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') {
clearPendingToolInputDelta(sessionId)
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) {
appendPendingToolInputDelta(sessionId, msg.toolInput)
if (!toolInputFlushTimerBySession.has(sessionId)) {
const timer = setTimeout(() => {
const text = consumePendingToolInputDelta(sessionId)
if (!text) return
update((s) => {
const partialInput = s.streamingToolInput + text
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,
}
}),
}
: {}),
}
})
}, 50)
toolInputFlushTimerBySession.set(sessionId, timer)
}
}
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': {
clearPendingToolInputDelta(sessionId)
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 appendedCompletionMessage = completionMessages !== session.messages
const notification = wasAgentRunning && appendedCompletionMessage
? 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
}