mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
2549 lines
89 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
}
|
|
|
|
function readXmlTag(xml: string, tag: string): string | undefined {
|
|
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))
|
|
return match?.[1] ? decodeXmlText(match[1].trim()) : undefined
|
|
}
|
|
|
|
function readNonEmptyString(record: Record<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
|
|
}
|