import { feature } from 'bun:bundle' import type { UUID } from 'crypto' import { relative } from 'path' import { getCwd } from 'src/utils/cwd.js' import { addInvokedSkill } from '../bootstrap/state.js' import { asSessionId } from '../types/ids.js' import type { AttributionSnapshotMessage, ContextCollapseCommitEntry, ContextCollapseSnapshotEntry, LogOption, PersistedWorktreeSession, SerializedMessage, } from '../types/logs.js' import type { Message, NormalizedMessage, NormalizedUserMessage, } from '../types/message.js' import { PERMISSION_MODES } from '../types/permissions.js' import { suppressNextSkillListing } from './attachments.js' import { copyFileHistoryForResume, type FileHistorySnapshot, } from './fileHistory.js' import { logError } from './log.js' import { createAssistantMessage, createUserMessage, filterOrphanedThinkingOnlyMessages, filterUnresolvedToolUses, filterWhitespaceOnlyAssistantMessages, isToolUseResultMessage, NO_RESPONSE_REQUESTED, normalizeMessages, } from './messages.js' import { copyPlanForResume } from './plans.js' import { processSessionStartHooks } from './sessionStart.js' import { buildConversationChain, checkResumeConsistency, getLastSessionLog, getSessionIdFromLog, isLiteLog, loadFullLog, loadMessageLogs, loadTranscriptFile, removeExtraFields, } from './sessionStorage.js' import type { ContentReplacementRecord } from './toolResultStorage.js' // Dead code elimination: ant-only tool names are conditionally required so // their strings don't leak into external builds. Static imports always bundle. /* eslint-disable @typescript-eslint/no-require-imports */ const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') ).BRIEF_TOOL_NAME : null const LEGACY_BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') ).LEGACY_BRIEF_TOOL_NAME : null const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? ( require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js') ).SEND_USER_FILE_TOOL_NAME : null /* eslint-enable @typescript-eslint/no-require-imports */ /** * Transforms legacy attachment types to current types for backward compatibility */ function migrateLegacyAttachmentTypes(message: Message): Message { if (message.type !== 'attachment') { return message } const attachment = message.attachment as { type: string [key: string]: unknown } // Handle legacy types not in current type system // Transform legacy attachment types if (attachment.type === 'new_file') { return { ...message, attachment: { ...attachment, type: 'file', displayPath: relative(getCwd(), attachment.filename as string), }, } as SerializedMessage // Cast entire message since we know the structure is correct } if (attachment.type === 'new_directory') { return { ...message, attachment: { ...attachment, type: 'directory', displayPath: relative(getCwd(), attachment.path as string), }, } as SerializedMessage // Cast entire message since we know the structure is correct } // Backfill displayPath for attachments from old sessions if (!('displayPath' in attachment)) { const path = 'filename' in attachment ? (attachment.filename as string) : 'path' in attachment ? (attachment.path as string) : 'skillDir' in attachment ? (attachment.skillDir as string) : undefined if (path) { return { ...message, attachment: { ...attachment, displayPath: relative(getCwd(), path), }, } as Message } } return message } export type TeleportRemoteResponse = { log: Message[] branch?: string } export type TurnInterruptionState = | { kind: 'none' } | { kind: 'interrupted_prompt'; message: NormalizedUserMessage } export type DeserializeResult = { messages: Message[] turnInterruptionState: TurnInterruptionState } /** * Deserializes messages from a log file into the format expected by the REPL. * Filters unresolved tool uses, orphaned thinking messages, and appends a * synthetic assistant sentinel when the last message is from the user. * @internal Exported for testing - use loadConversationForResume instead */ export function deserializeMessages(serializedMessages: Message[]): Message[] { return deserializeMessagesWithInterruptDetection(serializedMessages).messages } /** * Like deserializeMessages, but also detects whether the session was * interrupted mid-turn. Used by the SDK resume path to auto-continue * interrupted turns after a gateway-triggered restart. * @internal Exported for testing */ export function deserializeMessagesWithInterruptDetection( serializedMessages: Message[], ): DeserializeResult { try { // Transform legacy attachment types before processing const migratedMessages = serializedMessages.map( migrateLegacyAttachmentTypes, ) // Strip invalid permissionMode values from deserialized user messages. // The field is unvalidated JSON from disk and may contain modes from a different build. const validModes = new Set(PERMISSION_MODES) for (const msg of migratedMessages) { if ( msg.type === 'user' && msg.permissionMode !== undefined && !validModes.has(msg.permissionMode) ) { msg.permissionMode = undefined } } // Filter out unresolved tool uses and any synthetic messages that follow them const filteredToolUses = filterUnresolvedToolUses( migratedMessages, ) as NormalizedMessage[] // Filter out orphaned thinking-only assistant messages that can cause API errors // during resume. These occur when streaming yields separate messages per content // block and interleaved user messages prevent proper merging by message.id. const filteredThinking = filterOrphanedThinkingOnlyMessages( filteredToolUses, ) as NormalizedMessage[] // Filter out assistant messages with only whitespace text content. // This can happen when model outputs "\n\n" before thinking, user cancels mid-stream. const filteredMessages = filterWhitespaceOnlyAssistantMessages( filteredThinking, ) as NormalizedMessage[] const internalState = detectTurnInterruption(filteredMessages) // Transform mid-turn interruptions into interrupted_prompt by appending // a synthetic continuation message. This unifies both interruption kinds // so the consumer only needs to handle interrupted_prompt. let turnInterruptionState: TurnInterruptionState if (internalState.kind === 'interrupted_turn') { const [continuationMessage] = normalizeMessages([ createUserMessage({ content: 'Continue from where you left off.', isMeta: true, }), ]) filteredMessages.push(continuationMessage!) turnInterruptionState = { kind: 'interrupted_prompt', message: continuationMessage!, } } else { turnInterruptionState = internalState } // Append a synthetic assistant sentinel after the last user message so // the conversation is API-valid if no resume action is taken. Skip past // trailing system/progress messages and insert right after the user // message so removeInterruptedMessage's splice(idx, 2) removes the // correct pair. const lastRelevantIdx = filteredMessages.findLastIndex( m => m.type !== 'system' && m.type !== 'progress', ) if ( lastRelevantIdx !== -1 && filteredMessages[lastRelevantIdx]!.type === 'user' ) { filteredMessages.splice( lastRelevantIdx + 1, 0, createAssistantMessage({ content: NO_RESPONSE_REQUESTED, }) as NormalizedMessage, ) } return { messages: filteredMessages, turnInterruptionState } } catch (error) { logError(error as Error) throw error } } /** * Internal 3-way result from detection, before transforming interrupted_turn * into interrupted_prompt with a synthetic continuation message. */ type InternalInterruptionState = | TurnInterruptionState | { kind: 'interrupted_turn' } /** * Determines whether the conversation was interrupted mid-turn based on the * last message after filtering. An assistant as last message (after filtering * unresolved tool_uses) is treated as a completed turn because stop_reason is * always null on persisted messages in the streaming path. * * System and progress messages are skipped when finding the last turn-relevant * message — they are bookkeeping artifacts that should not mask a genuine * interruption. Attachments are kept as part of the turn. */ function detectTurnInterruption( messages: NormalizedMessage[], ): InternalInterruptionState { if (messages.length === 0) { return { kind: 'none' } } // Find the last turn-relevant message, skipping system/progress and // synthetic API error assistants. Error assistants are already filtered // before API send (normalizeMessagesForAPI) — skipping them here lets // auto-resume fire after retry exhaustion instead of reading the error as // a completed turn. const lastMessageIdx = messages.findLastIndex( m => m.type !== 'system' && m.type !== 'progress' && !(m.type === 'assistant' && m.isApiErrorMessage), ) const lastMessage = lastMessageIdx !== -1 ? messages[lastMessageIdx] : undefined if (!lastMessage) { return { kind: 'none' } } if (lastMessage.type === 'assistant') { // In the streaming path, stop_reason is always null on persisted messages // because messages are recorded at content_block_stop time, before // message_delta delivers the stop_reason. After filterUnresolvedToolUses // has removed assistant messages with unmatched tool_uses, an assistant as // the last message means the turn most likely completed normally. return { kind: 'none' } } if (lastMessage.type === 'user') { if (lastMessage.isMeta || lastMessage.isCompactSummary) { return { kind: 'none' } } if (isToolUseResultMessage(lastMessage)) { // Brief mode (#20467) drops the trailing assistant text block, so a // completed brief-mode turn legitimately ends on SendUserMessage's // tool_result. Without this check, resume misclassifies every // brief-mode session as interrupted mid-turn and injects a phantom // "Continue from where you left off." before the user's real next // prompt. Look back one step for the originating tool_use. if (isTerminalToolResult(lastMessage, messages, lastMessageIdx)) { return { kind: 'none' } } return { kind: 'interrupted_turn' } } // Plain text user prompt — CC hadn't started responding return { kind: 'interrupted_prompt', message: lastMessage } } if (lastMessage.type === 'attachment') { // Attachments are part of the user turn — the user provided context but // the assistant never responded. return { kind: 'interrupted_turn' } } return { kind: 'none' } } /** * Is this tool_result the output of a tool that legitimately terminates a * turn? SendUserMessage is the canonical case: in brief mode, calling it is * the turn's final act — there is no follow-up assistant text (#20467 * removed it). A transcript ending here means the turn COMPLETED, not that * it was killed mid-tool. * * Walks back to find the assistant tool_use that this result belongs to and * checks its name. The matching tool_use is typically the immediately * preceding relevant message (filterUnresolvedToolUses has already dropped * unpaired ones), but we walk just in case system/progress noise is * interleaved. */ function isTerminalToolResult( result: NormalizedUserMessage, messages: NormalizedMessage[], resultIdx: number, ): boolean { const content = result.message.content if (!Array.isArray(content)) return false const block = content[0] if (block?.type !== 'tool_result') return false const toolUseId = block.tool_use_id for (let i = resultIdx - 1; i >= 0; i--) { const msg = messages[i]! if (msg.type !== 'assistant') continue for (const b of msg.message.content) { if (b.type === 'tool_use' && b.id === toolUseId) { return ( b.name === BRIEF_TOOL_NAME || b.name === LEGACY_BRIEF_TOOL_NAME || b.name === SEND_USER_FILE_TOOL_NAME ) } } } return false } /** * Restores skill state from invoked_skills attachments in messages. * This ensures that skills are preserved across resume after compaction. * Without this, if another compaction happens after resume, the skills would be lost * because STATE.invokedSkills would be empty. * @internal Exported for testing - use loadConversationForResume instead */ export function restoreSkillStateFromMessages(messages: Message[]): void { for (const message of messages) { if (message.type !== 'attachment') { continue } if (message.attachment.type === 'invoked_skills') { for (const skill of message.attachment.skills) { if (skill.name && skill.path && skill.content) { // Resume only happens for the main session, so agentId is null addInvokedSkill(skill.name, skill.path, skill.content, null) } } } // A prior process already injected the skills-available reminder — it's // in the transcript the model is about to see. sentSkillNames is // process-local, so without this every resume re-announces the same // ~600 tokens. Fire-once latch; consumed on the first attachment pass. if (message.attachment.type === 'skill_listing') { suppressNextSkillListing() } } } /** * Chain-walk a transcript jsonl by path. Same sequence loadFullLog * runs internally — loadTranscriptFile → find newest non-sidechain * leaf → buildConversationChain → removeExtraFields — just starting * from an arbitrary path instead of the sid-derived one. * * leafUuids is populated by loadTranscriptFile as "uuids that no * other message's parentUuid points at" — the chain tips. There can * be several (sidechains, orphans); newest non-sidechain is the main * conversation's end. */ export async function loadMessagesFromJsonlPath(path: string): Promise<{ messages: SerializedMessage[] sessionId: UUID | undefined }> { const { messages: byUuid, leafUuids } = await loadTranscriptFile(path) let tip: (typeof byUuid extends Map ? T : never) | null = null let tipTs = 0 for (const m of byUuid.values()) { if (m.isSidechain || !leafUuids.has(m.uuid)) continue const ts = new Date(m.timestamp).getTime() if (ts > tipTs) { tipTs = ts tip = m } } if (!tip) return { messages: [], sessionId: undefined } const chain = buildConversationChain(byUuid, tip) return { messages: removeExtraFields(chain), // Leaf's sessionId — forked sessions copy chain[0] from the source // transcript, so the root retains the source session's ID. Matches // loadFullLog's mostRecentLeaf.sessionId. sessionId: tip.sessionId as UUID | undefined, } } /** * Loads a conversation for resume from various sources. * This is the centralized function for loading and deserializing conversations. * * @param source - The source to load from: * - undefined: load most recent conversation * - string: session ID to load * - LogOption: already loaded conversation * @param sourceJsonlFile - Alternate: path to a transcript jsonl. * Used when --resume receives a .jsonl path (cli/print.ts routes * on suffix), typically for cross-directory resume where the * transcript lives outside the current project dir. * @returns Object containing the deserialized messages and the original log, or null if not found */ export async function loadConversationForResume( source: string | LogOption | undefined, sourceJsonlFile: string | undefined, ): Promise<{ messages: Message[] turnInterruptionState: TurnInterruptionState fileHistorySnapshots?: FileHistorySnapshot[] attributionSnapshots?: AttributionSnapshotMessage[] contentReplacements?: ContentReplacementRecord[] contextCollapseCommits?: ContextCollapseCommitEntry[] contextCollapseSnapshot?: ContextCollapseSnapshotEntry sessionId: UUID | undefined // Session metadata for restoring agent context agentName?: string agentColor?: string agentSetting?: string customTitle?: string tag?: string mode?: 'coordinator' | 'normal' worktreeSession?: PersistedWorktreeSession | null prNumber?: number prUrl?: string prRepository?: string // Full path to the session file (for cross-directory resume) fullPath?: string } | null> { try { let log: LogOption | null = null let messages: Message[] | null = null let sessionId: UUID | undefined if (source === undefined) { // --continue: most recent session, skipping live --bg/daemon sessions // that are actively writing their own transcript. const logsPromise = loadMessageLogs() let skip = new Set() if (feature('BG_SESSIONS')) { try { const { listAllLiveSessions } = await import('./udsClient.js') const live = await listAllLiveSessions() skip = new Set( live.flatMap(s => s.kind && s.kind !== 'interactive' && s.sessionId ? [s.sessionId] : [], ), ) } catch { // UDS unavailable — treat all sessions as continuable } } const logs = await logsPromise log = logs.find(l => { const id = getSessionIdFromLog(l) return !id || !skip.has(id) }) ?? null } else if (sourceJsonlFile) { // --resume with a .jsonl path (cli/print.ts routes on suffix). // Same chain walk as the sid branch below — only the starting // path differs. const loaded = await loadMessagesFromJsonlPath(sourceJsonlFile) messages = loaded.messages sessionId = loaded.sessionId } else if (typeof source === 'string') { // Load specific session by ID log = await getLastSessionLog(source as UUID) sessionId = source as UUID } else { // Already have a LogOption log = source } if (!log && !messages) { return null } if (log) { // Load full messages for lite logs if (isLiteLog(log)) { log = await loadFullLog(log) } // Determine sessionId first so we can pass it to copy functions if (!sessionId) { sessionId = getSessionIdFromLog(log) as UUID } // Pass the original session ID to ensure the plan slug is associated with // the session we're resuming, not the temporary session ID before resume if (sessionId) { await copyPlanForResume(log, asSessionId(sessionId)) } // Copy file history for resume void copyFileHistoryForResume(log) messages = log.messages checkResumeConsistency(messages) } // Restore skill state from invoked_skills attachments before deserialization. // This ensures skills survive multiple compaction cycles after resume. restoreSkillStateFromMessages(messages!) // Deserialize messages to handle unresolved tool uses and ensure proper format const deserialized = deserializeMessagesWithInterruptDetection(messages!) messages = deserialized.messages // Process session start hooks for resume const hookMessages = await processSessionStartHooks('resume', { sessionId }) // Append hook messages to the conversation messages.push(...hookMessages) return { messages, turnInterruptionState: deserialized.turnInterruptionState, fileHistorySnapshots: log?.fileHistorySnapshots, attributionSnapshots: log?.attributionSnapshots, contentReplacements: log?.contentReplacements, contextCollapseCommits: log?.contextCollapseCommits, contextCollapseSnapshot: log?.contextCollapseSnapshot, sessionId, // Include session metadata for restoring agent context on resume agentName: log?.agentName, agentColor: log?.agentColor, agentSetting: log?.agentSetting, customTitle: log?.customTitle, tag: log?.tag, mode: log?.mode, worktreeSession: log?.worktreeSession, prNumber: log?.prNumber, prUrl: log?.prUrl, prRepository: log?.prRepository, // Include full path for cross-directory resume fullPath: log?.fullPath, } } catch (error) { logError(error as Error) throw error } }