import type { ThreadMessageLike } from '@assistant-ui/react' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' import type { SessionMessage } from '@/types/hermes' export type ChatMessagePart = Exclude[number] export type ChatMessage = { id: string role: SessionMessage['role'] parts: ChatMessagePart[] timestamp?: number pending?: boolean branchGroupId?: string hidden?: boolean } export type GatewayEventPayload = { text?: string rendered?: string status?: string message?: string name?: string tool_id?: string context?: string preview?: string summary?: string error?: string | boolean inline_diff?: string duration_s?: number todos?: unknown model?: string provider?: string reasoning_effort?: string service_tier?: string fast?: boolean running?: boolean cwd?: string branch?: string personality?: string // clarify.request request_id?: string question?: string choices?: string[] | null } export function textPart(text: string): ChatMessagePart { return { type: 'text', text } } export function reasoningPart(text: string): ChatMessagePart { return { type: 'reasoning', text } } const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g function unquoteMediaPath(value: string): string { const trimmed = value.trim() const quote = trimmed[0] return quote && quote === trimmed.at(-1) && ['"', "'", '`'].includes(quote) ? trimmed.slice(1, -1) : trimmed } function mediaLink(value: string): string { const path = unquoteMediaPath(value) return `[${mediaDisplayLabel(path)}](${mediaMarkdownHref(path)})` } export function renderMediaTags(text: string): string { return text .replace( MEDIA_LINE_RE, (_match, lead: string, value: string, trailer: string) => `${lead}${mediaLink(value)}${trailer}` ) .replace(MEDIA_TAG_RE, (_match, value: string) => mediaLink(value)) .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') } export function assistantTextPart(text: string): ChatMessagePart { return textPart(renderMediaTags(text)) } export function chatMessageText(message: ChatMessage): string { return message.parts .filter((part): part is Extract => part.type === 'text') .map(part => part.text) .join('') } const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/ const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/ const CONTEXT_REF_RE = /@(file|folder|url|image|tool):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g function textFromUnknown(value: unknown, depth = 0): string { if (typeof value === 'string') { return value } if (value === null || value === undefined) { return '' } if (depth > 2) { return '' } if (Array.isArray(value)) { return value.map(item => textFromUnknown(item, depth + 1)).join('') } if (typeof value === 'object') { const row = value as Record const textValue = row.text ?? row.output_text ?? row.content ?? row.message const nestedText = textFromUnknown(textValue, depth + 1) if (nestedText) { return nestedText } try { return JSON.stringify(value) } catch { return '' } } return String(value) } function displayContentForMessage(role: SessionMessage['role'], content: unknown): string { const textContent = textFromUnknown(content) if (role !== 'user') { return textContent } const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE) if (!marker || marker.index === undefined) { return textContent.replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() } const visibleText = textContent.slice(0, marker.index).replace(CONTEXT_WARNINGS_MARKER_RE, '').trim() const attachedContext = textContent.slice(marker.index + marker[0].length) const refs = [...new Set(Array.from(attachedContext.matchAll(CONTEXT_REF_RE)).map(match => match[0]))] return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText } export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { const next = [...parts] const last = next.at(-1) if (last?.type === 'text') { next[next.length - 1] = { ...last, text: `${last.text}${delta}` } return next } next.push(textPart(delta)) return next } export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { const next = appendTextPart(parts, delta) const last = next.at(-1) if (last?.type === 'text') { next[next.length - 1] = { ...last, text: renderMediaTags(last.text) } } return next } export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { const next = [...parts] const last = next.at(-1) if (last?.type === 'reasoning') { next[next.length - 1] = { ...last, text: `${last.text}${delta}` } return next } next.push(reasoningPart(delta)) return next } export function hasToolPart(message: ChatMessage): boolean { return message.parts.some(part => part.type === 'tool-call') } function toolId(payload: GatewayEventPayload | undefined): string { return payload?.tool_id || payload?.name || `tool-${Date.now()}` } function toolArgs(payload: GatewayEventPayload | undefined): Record { return { ...(payload?.context ? { context: payload.context } : {}), ...(payload?.preview ? { preview: payload.preview } : {}) } } function toolResult(payload: GatewayEventPayload | undefined): Record { return { ...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}), ...(payload?.summary ? { summary: payload.summary } : {}), ...(payload?.message ? { message: payload.message } : {}), ...(payload?.preview ? { preview: payload.preview } : {}), ...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}), ...(payload?.todos ? { todos: payload.todos } : {}), ...(payload?.error ? { error: payload.error } : {}) } } export function upsertToolPart( parts: ChatMessagePart[], payload: GatewayEventPayload | undefined, phase: 'running' | 'complete' ): ChatMessagePart[] { const id = toolId(payload) const name = payload?.name || 'tool' const next = [...parts] const index = next.findIndex( part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name) ) const base = { type: 'tool-call' as const, toolCallId: id, toolName: name, args: toolArgs(payload) as never, argsText: JSON.stringify(toolArgs(payload)), ...(phase === 'complete' ? { result: toolResult(payload), isError: Boolean(payload?.error) } : {}) } satisfies ChatMessagePart if (index === -1) { return [...next, base] } next[index] = { ...next[index], ...base } return next } function recordFromUnknown(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null } function parseMaybeJsonObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record } if (typeof value !== 'string' || !value.trim()) { return {} } try { const parsed = JSON.parse(value) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } catch { return {} } } function firstNonEmptyObject(...values: unknown[]): Record { for (const value of values) { const parsed = parseMaybeJsonObject(value) if (Object.keys(parsed).length > 0) { return parsed } } return {} } function parseStoredToolResult(content: unknown): unknown { if (content && typeof content === 'object') { return content } const textContent = textFromUnknown(content) if (!textContent.trim()) { return '' } try { return JSON.parse(textContent) } catch { return textContent } } function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessagePart { const row = recordFromUnknown(call) ?? {} const fn = recordFromUnknown(row.function) const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`) const toolName = String( row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool' ) const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input) return { type: 'tool-call', toolCallId: id, toolName, args: args as never, argsText: Object.keys(args).length ? JSON.stringify(args) : '' } } function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean { const toolCallId = toolMessage.tool_call_id || undefined const toolName = toolMessage.tool_name || toolMessage.name || 'tool' const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i] if (message.role !== 'assistant') { continue } const partIndex = message.parts.findIndex( part => part.type === 'tool-call' && ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) ) if (partIndex < 0) { continue } const parts = [...message.parts] const existing = parts[partIndex] parts[partIndex] = { ...existing, result: parseStoredToolResult(content), isError: false } as ChatMessagePart messages[i] = { ...message, parts } return true } return false } function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null { const toolCallId = toolMessage.tool_call_id || undefined const toolName = toolMessage.tool_name || toolMessage.name || 'tool' const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name const partIndex = parts.findIndex( part => part.type === 'tool-call' && ((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName)) ) if (partIndex < 0) { return null } const next = [...parts] const existing = next[partIndex] next[partIndex] = { ...existing, result: parseStoredToolResult(content), isError: false } as ChatMessagePart return next } function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart { const name = toolMessage.tool_name || toolMessage.name || 'tool' const context = textFromUnknown(toolMessage.context || toolMessage.text || toolMessage.content || '') const args = context ? { context } : {} return { type: 'tool-call', toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`, toolName: name, args: args as never, argsText: Object.keys(args).length ? JSON.stringify(args) : '', result: context ? { context } : {}, isError: false } } function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] { const seen = new Set() return messages.map(message => { let changed = false const parts = message.parts.map((part, index) => { if (part.type !== 'tool-call') { return part } const id = part.toolCallId || `${message.id}-tool-${index}` if (!seen.has(id)) { seen.add(id) if (part.toolCallId) { return part } changed = true return { ...part, toolCallId: id } as ChatMessagePart } changed = true const uniqueId = `${id}-${message.id}-${index}` seen.add(uniqueId) return { ...part, toolCallId: uniqueId } as ChatMessagePart }) return changed ? { ...message, parts } : message }) } export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { const result: ChatMessage[] = [] let pendingToolParts: ChatMessagePart[] = [] let pendingToolTimestamp: number | undefined const flushPendingTools = (index: number) => { if (!pendingToolParts.length) { return } result.push({ id: `${pendingToolTimestamp || Date.now()}-${index}-tools`, role: 'assistant', parts: pendingToolParts, timestamp: pendingToolTimestamp }) pendingToolParts = [] pendingToolTimestamp = undefined } messages.forEach((message, index) => { if (message.role === 'tool') { const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message) if (updatedPendingToolParts) { pendingToolParts = updatedPendingToolParts return } if (applyStoredToolResult(result, message)) { return } pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)] pendingToolTimestamp ??= message.timestamp return } const content = message.content || message.text || message.context || message.name const displayContent = displayContentForMessage(message.role, content) const parts: ChatMessagePart[] = [] const reasoning = message.reasoning || message.reasoning_content || (typeof message.reasoning_details === 'string' ? message.reasoning_details : '') if (reasoning && message.role === 'assistant') { parts.push(reasoningPart(reasoning)) } if (displayContent) { parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent)) } if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex))) } if (!parts.length) { return } const isToolOnlyAssistant = message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call') if (isToolOnlyAssistant) { pendingToolParts = [...pendingToolParts, ...parts] pendingToolTimestamp ??= message.timestamp return } if (message.role === 'assistant' && pendingToolParts.length) { const last = result.at(-1) if (last?.role === 'assistant') { last.parts = [...last.parts, ...pendingToolParts, ...parts] last.timestamp = message.timestamp ?? last.timestamp pendingToolParts = [] pendingToolTimestamp = undefined return } parts.unshift(...pendingToolParts) pendingToolParts = [] pendingToolTimestamp = undefined } else if (message.role !== 'assistant') { flushPendingTools(index) } result.push({ id: `${message.timestamp || Date.now()}-${index}-${message.role}`, role: message.role, parts, timestamp: message.timestamp }) }) flushPendingTools(messages.length) return withUniqueToolCallIds( result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text')) ) } export function branchGroupForUser(userMessage: ChatMessage): string { return `branch:${userMessage.id}` }