From ce420bf1c6825080d4c7ec9e155f96039d3376e7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 12:44:30 +0800 Subject: [PATCH] refactor(tui): unify resume replay rendering (#88) * fix(tui): render rejected plan reviews in plan box * fix(tui): keep final approved plan in replay * refactor(tui): unify resume replay rendering --- .changeset/replay-live-tool-groups.md | 5 + apps/kimi-code/AGENTS.md | 9 +- apps/kimi-code/src/tui/actions/replay-ops.ts | 703 ------------------ .../src/tui/components/messages/plan-box.ts | 29 +- .../src/tui/components/messages/tool-call.ts | 24 +- apps/kimi-code/src/tui/kimi-tui.ts | 486 ++++++++++-- .../src/tui/utils/hook-result-format.ts | 18 + .../kimi-code/src/tui/utils/message-replay.ts | 294 ++++++++ .../replay-hydrate-agent-group.test.ts | 415 ----------- .../tui/components/messages/tool-call.test.ts | 20 +- .../tui/components/panels/plan-box.test.ts | 26 + .../test/tui/kimi-tui-message-flow.test.ts | 84 ++- .../kimi-code/test/tui/message-replay.test.ts | 501 +++++++++++++ apps/kimi-code/test/tui/replay-ops.test.ts | 556 -------------- 14 files changed, 1433 insertions(+), 1737 deletions(-) create mode 100644 .changeset/replay-live-tool-groups.md delete mode 100644 apps/kimi-code/src/tui/actions/replay-ops.ts create mode 100644 apps/kimi-code/src/tui/utils/hook-result-format.ts create mode 100644 apps/kimi-code/src/tui/utils/message-replay.ts delete mode 100644 apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts create mode 100644 apps/kimi-code/test/tui/message-replay.test.ts delete mode 100644 apps/kimi-code/test/tui/replay-ops.test.ts diff --git a/.changeset/replay-live-tool-groups.md b/.changeset/replay-live-tool-groups.md new file mode 100644 index 000000000..09ab12546 --- /dev/null +++ b/.changeset/replay-live-tool-groups.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refactor TUI resume replay logic. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index 9c8f422cf..c9ec362bd 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -14,7 +14,6 @@ Main directories: - `src/cli/`: command-line arguments, subcommands, and CLI startup. - `src/tui/`: the interactive terminal UI. - `src/tui/kimi-tui.ts`: the TUI master assembler, responsible for wiring state, layout, editor, session, SDK events, and dialogs together. -- `src/tui/actions/`: reusable TUI state/replay/projection logic. Pure logic that can be split out of `KimiTUI` should land here first. - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. - `src/tui/components/`: pi-tui components, organized by UI type. - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. @@ -32,12 +31,13 @@ Main directories: ## Module Responsibilities - `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI. -- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `actions`, `commands`, `components`, `reverse-rpc`, or `utils` first. +- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `commands`, `components`, `reverse-rpc`, or `utils` first. - `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward. - `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly. - `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response. - `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly. - `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`. +- Resume replay orchestration lives in the `Session Replay` section of `KimiTUI`, because it intentionally drives the same stateful render hooks as live events. Stateless replay parsing, limiting, and projection helpers belong in `src/tui/utils/message-replay.ts`. - `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code. ## KimiTUI Internal Sections @@ -51,6 +51,7 @@ Main directories: - User input: `handleUserInput`, `executeSlashCommand`, `handleBuiltInSlashCommand`, `sendNormalUserInput`. - Sending and queueing: `enqueueMessage`, `sendMessageInternal`, `sendMessage`, `steerMessage`, `finalizeTurn`. - Session management: create, restore, switch, close, sync runtime state, subscribe to session events. +- Session replay: hydrate resume snapshots, drive replay records through live render hooks, and clean up transient replay state. - Event routing: `handleEvent` only dispatches; concrete events go into the corresponding `handleXxx`. - Streaming rendering: assistant delta, thinking, tool call, tool result, compaction, subagent, background agent. - Transcript: `createTranscriptComponent`, `appendTranscriptEntry`, read/tool/agent group aggregation. @@ -66,13 +67,13 @@ The feature type decides where it lands: - New CLI arguments: change `src/cli/commands.ts` / `src/cli/options.ts`, then pass them into the TUI via `src/cli/run-shell.ts`. Do not let the CLI operate on the session directly. - New CLI subcommands: put them under `src/cli/sub/`, with non-interactive command logic only; when SDK access is needed, go through `@moonshot-ai/kimi-code-sdk`. -- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `actions` or `utils`. +- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `utils` or focused components when it has no reason to stay in `KimiTUI`. - New skill-derived commands: hook into `buildSkillSlashCommands` / the skill command map — do not hard-code a single skill. - New transcript message types: define the data shape in `src/tui/types.ts`, add or extend a component under `components/messages/`, and register the renderer in `createTranscriptComponent`. - New tool-result display: prefer extending `components/messages/tool-renderers/registry.ts` and the corresponding renderer; do not stack branches inside `ToolCallComponent`. - New popup / selector: put it under `components/dialogs/` and mount it via `mountEditorReplacement`; if the trigger comes from an SDK callback, also check whether `reverse-rpc/` needs an adapter/controller/handler. - New SDK event handling: add the dispatch in `handleEvent`, then add the corresponding `handleXxx`. If the event simply maps to a transcript entry. -- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration. +- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration. New resume replay behavior belongs in the `Session Replay` section and should reuse live rendering paths where possible. - New status bar, activity area, or queue display: change `chrome/footer`, `panes/activity`, `panes/queue`, and the corresponding `updateXxx` method. - New configuration option: first change the read/write and schema in `src/tui/config.ts`, then wire the settings UI; when persistence is needed, go through `saveTuiConfig`. - New constants: constants shared by CLI/TUI and not copy belong in `src/constant/`; non-copy constants reused only within the TUI belong in `src/tui/constant/`. Component-local copy, option labels, help descriptions, dialog title/footer text — keep these next to the corresponding component or command, do not centralize them into a global copy constants module. diff --git a/apps/kimi-code/src/tui/actions/replay-ops.ts b/apps/kimi-code/src/tui/actions/replay-ops.ts deleted file mode 100644 index b88d66290..000000000 --- a/apps/kimi-code/src/tui/actions/replay-ops.ts +++ /dev/null @@ -1,703 +0,0 @@ -/** - * Session replay hydration. - * - * Core owns durable history as raw session records. The TUI projects those - * records into the same transcript entries/components used by live events, - * without mutating core session state or responding to replayed data. - */ - -import type { - AgentReplayRecord, - BackgroundTaskInfo, - ContentPart, - ContextMessage, - PromptOrigin, - PermissionMode, - ResumedAgentState, - Session, - ToolCall, -} from '@moonshot-ai/kimi-code-sdk'; - -import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; -import type { TodoItem } from '#/tui/components/chrome/todo-panel'; -import { ToolCallComponent } from '#/tui/components/messages/tool-call'; -import type { TUIState } from '#/tui/kimi-tui'; -import type { - AppState, - BackgroundAgentMetadata, - BackgroundAgentStatusData, - ToolCallBlockData, - TranscriptEntry, -} from '#/tui/types'; -import { formatErrorMessage, isTodoItemShape } from '#/tui/utils/event-payload'; -import { formatBackgroundAgentTranscript } from '#/tui/utils/background-agent-status'; -import { mediaUrlPartToText } from '#/tui/utils/media-url'; -import { nextTranscriptId } from '#/tui/utils/transcript-id'; - -export interface ReplayHydrationHooks { - readonly setAppState: (patch: Partial) => void; - readonly appendEntry: (entry: TranscriptEntry) => void; - readonly setTodoList: (todos: readonly TodoItem[]) => void; - readonly emitError: (message: string) => void; -} - -interface ReplayProjection { - readonly entries: readonly TranscriptEntry[]; - /** - * Background subagents still not completed or failed when replay ends, - * keyed by agent_id. `hydrateTranscriptFromReplay` seeds - * `state.backgroundAgents` from this so the footer badge starts accurate. - */ - readonly backgroundAgents: ReadonlySet; - /** - * Background agent metadata that remains needed after replay so live - * terminal events can keep rendering transcript copy after resume. - */ - readonly backgroundAgentMetadata: ReadonlyMap; -} - -interface OpenAssistant { - thinking: string[]; - text: string[]; -} - -interface ProjectionState { - entries: TranscriptEntry[]; - toolCalls: Map; - assistant: OpenAssistant; - skillActivationIds: Set; - permissionMode?: PermissionMode; - backgroundAgents: Set; - backgroundAgentMetadata: Map; - backgroundTasks: ReadonlyMap; -} - -type BackgroundTaskOrigin = Extract; - -const REPLAY_TURN_LIMIT = 10; - -export async function hydrateTranscriptFromReplay( - state: TUIState, - hooks: ReplayHydrationHooks, - session: Session, -): Promise { - hooks.setAppState({ isReplaying: true }); - try { - const main = session.getResumeState()?.agents['main']; - if (main === undefined) { - hooks.emitError('Session history is unavailable for this session.'); - return false; - } - - const projection = projectReplayRecords(main.replay, main.background); - hydrateProjectedEntries(state, projection.entries, hooks.appendEntry); - hydrateTodoPanelFromResume(main, hooks); - state.backgroundAgents = new Set(projection.backgroundAgents); - state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); - - // Seed the BPM-derived store from the resume snapshot. This is the - // authoritative source for footer count + transcript dedupe; the - // subagent-derived `backgroundAgents` set above is kept for legacy - // metadata lookups (agent name / description) until removed. - state.backgroundTasks = new Map( - main.background.map((info) => [info.taskId, info]), - ); - state.backgroundTaskTranscriptedTerminal.clear(); - // Resumed terminal tasks should not re-emit transcript cards. - for (const info of main.background) { - if ( - info.status === 'completed' || - info.status === 'failed' || - info.status === 'killed' || - info.status === 'lost' - ) { - state.backgroundTaskTranscriptedTerminal.add(info.taskId); - } - } - const counts = countActiveBackgroundTasks(state.backgroundTasks); - state.footer.setBackgroundCounts(counts); - hooks.setAppState(appStateFromResumeAgent(main)); - return true; - } catch (error) { - const message = formatErrorMessage(error); - hooks.emitError(`Failed to replay session history: ${message}`); - return false; - } finally { - hooks.setAppState({ isReplaying: false }); - } -} - -function hydrateTodoPanelFromResume( - agent: ResumedAgentState, - hooks: ReplayHydrationHooks, -): void { - const rawTodos = agent.toolStore?.['todo']; - if (!Array.isArray(rawTodos)) { - hooks.setTodoList([]); - return; - } - const todos = rawTodos - .filter((todo): todo is TodoItem => isTodoItemShape(todo)) - .map((todo) => ({ title: todo.title, status: todo.status })); - if (todos.length > 0 && todos.every((t) => t.status === 'done')) { - hooks.setTodoList([]); - return; - } - hooks.setTodoList(todos); -} - -function countActiveBackgroundTasks(tasks: ReadonlyMap): { - bashTasks: number; - agentTasks: number; -} { - let bashTasks = 0; - let agentTasks = 0; - for (const info of tasks.values()) { - if ( - info.status === 'completed' || - info.status === 'failed' || - info.status === 'killed' || - info.status === 'lost' - ) { - continue; - } - if (info.taskId.startsWith('agent-')) { - agentTasks += 1; - } else { - bashTasks += 1; - } - } - return { bashTasks, agentTasks }; -} - -function appStateFromResumeAgent(agent: ResumedAgentState): Partial { - const maxContextTokens = agent.config.modelCapabilities?.max_context_tokens ?? 0; - const contextTokens = agent.context.tokenCount; - const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; - return { - // `?? ''` so a resumed session with no resolvable model yields a string, - // not `undefined` — the editor's `appState.model.trim()` would otherwise - // throw. An empty model surfaces the normal "LLM not set" state. - model: agent.config.modelAlias ?? agent.config.provider?.model ?? '', - contextTokens, - maxContextTokens, - contextUsage, - planMode: agent.plan !== null, - yolo: agent.permission.mode === 'yolo', - permissionMode: agent.permission.mode, - }; -} - -export function projectReplayRecords( - records: readonly AgentReplayRecord[], - backgroundTasks: readonly BackgroundTaskInfo[] = [], -): ReplayProjection { - const state: ProjectionState = { - entries: [], - toolCalls: new Map(), - assistant: { thinking: [], text: [] }, - skillActivationIds: new Set(), - backgroundAgents: new Set(), - backgroundAgentMetadata: new Map(), - backgroundTasks: new Map(backgroundTasks.map((info) => [info.taskId, info])), - }; - - for (const record of limitReplayRecordsByTurn(records, REPLAY_TURN_LIMIT)) { - projectReplayRecord(state, record); - } - flushAssistant(state); - - return { - entries: state.entries, - backgroundAgents: state.backgroundAgents, - backgroundAgentMetadata: state.backgroundAgentMetadata, - }; -} - -function limitReplayRecordsByTurn( - records: readonly AgentReplayRecord[], - maxTurns: number, -): readonly AgentReplayRecord[] { - if (maxTurns <= 0) return []; - - const turnStarts = records.flatMap((record, index) => - isReplayUserTurnRecord(record) ? [index] : [], - ); - if (turnStarts.length <= maxTurns) return records; - - return records.slice(turnStarts[turnStarts.length - maxTurns]); -} - -function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { - if (record.type !== 'message') return false; - const { message } = record; - if (message.role !== 'user') return false; - switch (message.origin?.kind) { - case undefined: - case 'user': - return true; - case 'skill_activation': - return message.origin.trigger === 'user-slash'; - case 'background_task': - case 'compaction_summary': - case 'hook_result': - case 'injection': - case 'system_trigger': - return false; - } -} - -function projectReplayRecord(state: ProjectionState, record: AgentReplayRecord): void { - switch (record.type) { - case 'message': - projectContextMessage(state, record.message); - return; - case 'plan_updated': - flushAssistant(state); - state.entries.push(entry('status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice')); - return; - case 'permission_updated': - flushAssistant(state); - projectPermissionUpdate(state, record.mode); - return; - case 'approval_result': { - flushAssistant(state); - const { record: approvalRecord } = record; - const { result } = approvalRecord; - const parts: string[] = []; - switch (result.decision) { - case 'approved': - parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); - break; - case 'rejected': - parts.push('Rejected'); - break; - case 'cancelled': - parts.push('Cancelled'); - break; - } - parts.push(`: ${approvalRecord.action}`); - if (result.feedback !== undefined && result.feedback.length > 0) { - parts.push(` — "${result.feedback}"`); - } - state.entries.push(entry('status', parts.join(''), 'notice')); - return; - } - case 'config_updated': - return; - } -} - -function projectPermissionUpdate(state: ProjectionState, mode: PermissionMode): void { - if (mode === 'yolo') { - state.entries.push( - entry('status', 'YOLO mode: ON', 'notice', { - detail: 'All actions will be approved automatically. Use with caution.', - }), - ); - state.permissionMode = mode; - return; - } - if (state.permissionMode === 'yolo' && mode === 'manual') { - state.entries.push(entry('status', 'YOLO mode: OFF', 'notice')); - state.permissionMode = mode; - return; - } - state.entries.push(entry('status', `Permission mode: ${mode}`, 'notice')); - state.permissionMode = mode; -} - -interface SkillActivationProjection { - readonly activationId: string; - readonly skillName: string; - readonly skillArgs?: string; -} - -function projectSkillActivation( - state: ProjectionState, - skill: SkillActivationProjection | undefined, -): void { - if (skill === undefined) return; - if (state.skillActivationIds.has(skill.activationId)) return; - state.skillActivationIds.add(skill.activationId); - state.entries.push( - entry('skill_activation', `Activated skill: ${skill.skillName}`, 'plain', { - skillActivationId: skill.activationId, - skillName: skill.skillName, - skillArgs: skill.skillArgs, - }), - ); -} - -function projectContextMessage(state: ProjectionState, message: ContextMessage): void { - switch (message.role) { - case 'user': { - const origin = backgroundOrigin(message); - if (origin !== undefined) { - flushAssistant(state); - projectBackgroundTaskNotification(state, origin); - return; - } - if (message.origin?.kind === 'hook_result') { - projectHookResultMessage(state, message); - return; - } - if (message.origin?.kind === 'injection') { - return; - } - flushAssistant(state); - const skill = skillActivationFromOrigin(message.origin); - if (skill !== undefined) { - projectSkillActivation(state, skill); - return; - } - state.entries.push(entry('user', contentPartsToText(message.content), 'plain')); - return; - } - case 'assistant': - if (message.origin?.kind === 'hook_result') { - projectHookResultMessage(state, message); - projectMessageToolCalls(state, message.toolCalls); - return; - } - collectMessageContent(state.assistant, message.content); - flushAssistant(state); - projectMessageToolCalls(state, message.toolCalls); - return; - case 'tool': - flushAssistant(state); - projectMessageToolResult(state, message); - return; - case 'system': - return; - default: - return; - } -} - -function projectMessageToolCalls(state: ProjectionState, toolCalls: readonly ToolCall[]): void { - for (const rawToolCall of toolCalls) { - const toolCall = toolCallFromMessage(rawToolCall); - if (toolCall === undefined) continue; - state.toolCalls.set(toolCall.id, toolCall); - state.entries.push( - entry('tool_call', '', 'plain', { - toolCallData: toolCall, - }), - ); - } -} - -function projectMessageToolResult(state: ProjectionState, message: ContextMessage): void { - const toolCallId = message.toolCallId; - if (toolCallId === undefined) return; - const call = state.toolCalls.get(toolCallId); - if (call === undefined) return; - call.result = { - tool_call_id: toolCallId, - output: toolResultOutput(message.content), - is_error: message.isError, - }; -} - -function projectBackgroundTaskNotification( - state: ProjectionState, - origin: BackgroundTaskOrigin, -): void { - const task = state.backgroundTasks.get(origin.taskId); - const meta: BackgroundAgentMetadata = { - agentId: origin.taskId, - parentToolCallId: origin.taskId, - description: task?.description, - }; - let status = formatBackgroundAgentTranscript( - origin.status === 'completed' ? 'completed' : 'failed', - meta, - ); - if (origin.status === 'lost') { - status = { - ...status, - headline: status.headline.replace(' failed in background', ' lost in background'), - }; - } else if (origin.status === 'killed') { - status = { - ...status, - headline: status.headline.replace(' failed in background', ' stopped'), - }; - } - state.entries.push( - entry('status', status.headline, 'plain', { - detail: status.detail, - backgroundAgentStatus: status, - }), - ); - state.backgroundAgents.delete(meta.agentId); - state.backgroundAgentMetadata.delete(meta.agentId); -} - -function toolResultOutput(content: readonly ContentPart[]): string { - if (content.some((part) => part.type !== 'text')) { - return JSON.stringify(content); - } - return contentPartsToText(content); -} - -function flushAssistant(state: ProjectionState): void { - const thinking = state.assistant.thinking.join(''); - const text = state.assistant.text.join(''); - state.assistant = { thinking: [], text: [] }; - if (thinking.length > 0) { - state.entries.push(entry('thinking', thinking, 'plain')); - } - if (text.length > 0) { - state.entries.push(entry('assistant', text, 'markdown')); - } -} - -function projectHookResultMessage(state: ProjectionState, message: ContextMessage): void { - if (message.origin?.kind !== 'hook_result') return; - flushAssistant(state); - state.entries.push( - entry( - 'assistant', - formatHookResultMessageForTranscript( - contentPartsToText(message.content), - message.origin.event, - message.origin.blocked === true, - ), - 'markdown', - ), - ); -} - -const HOOK_RESULT_RE = - /\n?([\s\S]*?)\n?<\/hook_result>/g; - -function formatHookResultMessageForTranscript( - text: string, - fallbackEvent: string, - blocked: boolean, -): string { - const results: Array<{ event: string; body: string }> = []; - let lastIndex = 0; - - for (const match of text.matchAll(HOOK_RESULT_RE)) { - if (text.slice(lastIndex, match.index).trim().length > 0) { - return formatHookResultBlock(fallbackEvent, text, blocked); - } - const event = match[1]; - const body = match[2]; - if (event === undefined || body === undefined) { - return formatHookResultBlock(fallbackEvent, text, blocked); - } - results.push({ event, body }); - lastIndex = match.index + match[0].length; - } - - if (results.length === 0 || text.slice(lastIndex).trim().length > 0) { - return formatHookResultBlock(fallbackEvent, text, blocked); - } - - return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n'); -} - -function formatHookResultBlock(event: string, body: string, blocked: boolean): string { - return `*${event} hook${blocked ? ' blocked' : ''}*\n\n${body.trim() || '(empty)'}`; -} - -function collectMessageContent(target: OpenAssistant, content: readonly ContentPart[]): void { - for (const part of content) { - switch (part.type) { - case 'think': - target.thinking.push(part.think); - break; - case 'text': - target.text.push(part.text); - break; - case 'audio_url': - case 'image_url': - case 'video_url': - break; - } - } -} - -function toolCallFromMessage(rawToolCall: ToolCall): ToolCallBlockData | undefined { - const id = rawToolCall.id; - const name = rawToolCall.name; - if (id.length === 0 || name.length === 0) return undefined; - return { - id, - name, - args: parseToolArguments(rawToolCall.arguments), - }; -} - -function parseToolArguments(value: string | null): Record { - if (value === null || value.length === 0) return {}; - try { - const parsed = JSON.parse(value); - return isObject(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function entry( - kind: TranscriptEntry['kind'], - content: string, - renderMode: TranscriptEntry['renderMode'], - extras?: { - turnId?: string; - toolCallData?: ToolCallBlockData; - detail?: string; - color?: string; - backgroundAgentStatus?: BackgroundAgentStatusData; - skillActivationId?: string; - skillName?: string; - skillArgs?: string; - }, -): TranscriptEntry { - return { - id: nextTranscriptId(), - kind, - renderMode, - content, - turnId: extras?.turnId, - detail: extras?.detail, - color: extras?.color, - toolCallData: extras?.toolCallData, - backgroundAgentStatus: extras?.backgroundAgentStatus, - skillActivationId: extras?.skillActivationId, - skillName: extras?.skillName, - skillArgs: extras?.skillArgs, - }; -} - -function contentPartsToText(content: readonly ContentPart[]): string { - return content.map(userPartToText).join(''); -} - -function backgroundOrigin(message: ContextMessage): BackgroundTaskOrigin | undefined { - return message.origin?.kind === 'background_task' ? message.origin : undefined; -} - -function userPartToText(part: ContentPart): string { - switch (part.type) { - case 'text': - return part.text; - case 'think': - return part.think; - case 'image_url': - return mediaUrlPartToText('image', part.imageUrl.url); - case 'video_url': - return mediaUrlPartToText('video', part.videoUrl.url); - case 'audio_url': - return mediaUrlPartToText('audio', part.audioUrl.url); - } -} - -function skillActivationFromOrigin( - origin: PromptOrigin | undefined, -): SkillActivationProjection | undefined { - if (origin?.kind !== 'skill_activation') return undefined; - return { - activationId: origin.activationId, - skillName: origin.skillName, - skillArgs: origin.skillArgs, - }; -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** - * Inject projected flat entries into live state. Adjacent Agent tool_call - * entries sharing `(turnId, step)` are grouped into an AgentGroupComponent so - * replay matches live behavior. Other entries use the original append path. - * - * Unlike `tryAttachAgentToolCall`, this does not write - * `state.pendingAgentGroup`; after replay, live events must take over from a - * clean pending group state. - */ -export function hydrateProjectedEntries( - state: TUIState, - entries: readonly TranscriptEntry[], - appendEntry: (entry: TranscriptEntry) => void, -): void { - let i = 0; - while (i < entries.length) { - const cur = entries[i]; - if (cur === undefined) { - i += 1; - continue; - } - if (cur.kind === 'skill_activation' && cur.skillActivationId !== undefined) { - if (state.renderedSkillActivationIds.has(cur.skillActivationId)) { - i += 1; - continue; - } - state.renderedSkillActivationIds.add(cur.skillActivationId); - } - const tc = cur.toolCallData; - if ( - cur.kind === 'tool_call' && - tc !== undefined && - tc.name === 'Agent' && - tc.step !== undefined - ) { - // Collect all adjacent Agent calls with the same step and turn id. - const batch: TranscriptEntry[] = [cur]; - let j = i + 1; - while (j < entries.length) { - const next = entries[j]; - if (next === undefined) break; - const nextTc = next.toolCallData; - if ( - next.kind === 'tool_call' && - nextTc !== undefined && - nextTc.name === 'Agent' && - nextTc.step === tc.step && - nextTc.turnId === tc.turnId - ) { - batch.push(next); - j++; - continue; - } - break; - } - if (batch.length >= 2) { - attachAgentBatchAsGroup(state, batch); - i = j; - continue; - } - // A single Agent stays on the standalone card path. - } - appendEntry(cur); - i++; - } -} - -function attachAgentBatchAsGroup(state: TUIState, batch: readonly TranscriptEntry[]): void { - const group = new AgentGroupComponent(state.theme.colors, state.ui); - state.transcriptContainer.addChild(group); - for (const item of batch) { - const tc = item.toolCallData; - if (tc === undefined) continue; - state.transcriptEntries.push(item); - const component = new ToolCallComponent( - tc, - tc.result, - state.theme.colors, - state.ui, - state.theme.markdownTheme, - state.appState.workDir, - ); - if (state.toolOutputExpanded) component.setExpanded(true); - if (state.planExpanded) component.setPlanExpanded(true); - state.pendingToolComponents.set(tc.id, component); - group.attach(tc.id, component); - } - state.ui.requestRender(); -} diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index 8f16e0580..b79b7abb7 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -19,12 +19,17 @@ const TITLE_SUFFIX = ' '; export interface PlanBoxOptions { maxContentLines?: number; expanded?: boolean; + status?: { + readonly label: string; + readonly colorHex: string; + }; } export class PlanBoxComponent implements Component { private readonly markdown: Markdown; private readonly maxContentLines: number | undefined; private readonly expanded: boolean; + private readonly status: PlanBoxOptions['status']; private cachedWidth: number | undefined; private cachedLines: string[] | undefined; @@ -42,6 +47,7 @@ export class PlanBoxComponent implements Component { this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); this.maxContentLines = opts?.maxContentLines; this.expanded = opts?.expanded ?? false; + this.status = opts?.status; } invalidate(): void { @@ -105,17 +111,26 @@ export class PlanBoxComponent implements Component { private buildTitle(horzLen: number): string { const fallback = ' plan '; - const planPath = this.planPath; - if (planPath === undefined || planPath.length === 0) return fallback; - const basename = path.basename(planPath); - if (basename.length === 0) return fallback; - const wrapperLen = TITLE_PREFIX.length + TITLE_SUFFIX.length; + const statusSuffix = this.buildStatusSuffix(); + const fallbackWithStatus = ` plan${statusSuffix} `; const budget = horzLen - 1; - if (wrapperLen + basename.length > budget) return fallback; + const fallbackTitle = visibleWidth(fallbackWithStatus) <= budget ? fallbackWithStatus : fallback; + const planPath = this.planPath; + if (planPath === undefined || planPath.length === 0) return fallbackTitle; + const basename = path.basename(planPath); + if (basename.length === 0) return fallbackTitle; const linked = path.isAbsolute(planPath) ? toTerminalHyperlink(basename, pathToFileURL(planPath).href) : basename; - return TITLE_PREFIX + linked + TITLE_SUFFIX; + const title = TITLE_PREFIX + linked + statusSuffix + TITLE_SUFFIX; + if (visibleWidth(title) > budget) return fallbackTitle; + return title; + } + + private buildStatusSuffix(): string { + const status = this.status; + if (status === undefined || status.label.length === 0) return ''; + return ` · ${chalk.hex(status.colorHex)(status.label)}`; } } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 2c84ed3ff..6887070b3 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -191,6 +191,16 @@ function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome { return path !== undefined && path.length > 0 ? { kind: 'approved', path } : { kind: 'approved' }; } +function isExitPlanModeOutcomeOutput(output: string): boolean { + return ( + output.startsWith(REJECT_PREFIX) || + output.startsWith(PLAN_REJECT_PREFIX) || + output.startsWith('Exited plan mode.') || + APPROVED_OPTION_RE.test(output) || + output.includes(APPROVED_PLAN_MARKER) + ); +} + function unescapeJsonString(s: string): string { return s.replaceAll(/\\(["\\/bfnrt])/g, (_, ch: string) => { switch (ch) { @@ -1042,7 +1052,7 @@ export class ToolCallComponent extends Container { : 'Approved'; return `${label}${chalk.hex(colors.success)(` · ${chipText}`)}`; } - return `${label}${chalk.hex(colors.error)(' · Rejected')}`; + return label; } if (toolCall.name === 'AskUserQuestion') { @@ -1549,6 +1559,7 @@ export class ToolCallComponent extends Container { new PlanBoxComponent(plan, this.markdownTheme, this.colors.success, path, { maxContentLines: this.computePlanBoxMaxContentLines(), expanded: this.planExpanded, + status: this.resolvePlanBoxStatus(), }), ); } else { @@ -1582,6 +1593,15 @@ export class ToolCallComponent extends Container { return this.planPath; } + private resolvePlanBoxStatus(): { label: string; colorHex: string } | undefined { + const result = this.result; + if (this.toolCall.name !== 'ExitPlanMode' || result === undefined) return undefined; + if (!isExitPlanModeOutcomeOutput(result.output)) return undefined; + const outcome = interpretExitPlanModeOutcome(result.output); + if (outcome.kind !== 'rejected') return undefined; + return { label: 'Rejected', colorHex: this.colors.error }; + } + private buildContent(): void { const { result } = this; if (result === undefined || !result.output) return; @@ -1597,7 +1617,7 @@ export class ToolCallComponent extends Container { return; } - if (this.toolCall.name === 'ExitPlanMode' && !result.is_error) { + if (this.toolCall.name === 'ExitPlanMode' && isExitPlanModeOutcomeOutput(result.output)) { // Approved plans are already rendered by buildCallPreview via // resolvePlanForPreview. Rejected or revise feedback uses a warning label // plus normal body text so it remains visible in the transcript. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a185e3865..79ed27c4b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -48,6 +48,7 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import { BUILT_IN_CATALOG_JSON } from '../built-in-catalog'; import type { + AgentReplayRecord, AgentStatusUpdatedEvent, ApprovalRequest, ApprovalResponse, @@ -61,6 +62,7 @@ import type { CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent, + ContextMessage, CreateSessionOptions, ErrorEvent, Event, @@ -69,7 +71,9 @@ import type { ModelAlias, McpServerInfo, PermissionMode, + PromptOrigin, PromptPart, + ResumedAgentState, Session, SessionMetaUpdatedEvent, SessionStatus, @@ -79,6 +83,7 @@ import type { SubagentFailedEvent, SubagentSpawnedEvent, ThinkingDeltaEvent, + ToolCall, ToolCallDeltaEvent, ToolCallStartedEvent, ToolProgressEvent, @@ -103,7 +108,6 @@ import { getInputHistoryFile } from '#/utils/paths'; import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; import { detectFdPath } from '#/utils/process/fd-detect'; -import { hydrateTranscriptFromReplay, type ReplayHydrationHooks } from './actions/replay-ops'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, @@ -235,9 +239,29 @@ import { stringValue, } from './utils/event-payload'; import { isAbortError } from './utils/errors'; +import { formatHookResultMarkdown, formatHookResultPlain } from './utils/hook-result-format'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { McpOAuthAuthorizationUrlOpener } from './utils/mcp-oauth'; +import { + appStateFromResumeAgent, + backgroundOrigin, + collectReplayMessageContent, + contentPartsToText, + countActiveBackgroundTasks, + createReplayRenderContext, + formatHookResultMessageForTranscript, + isTerminalBackgroundTask, + limitReplayRecordsByTurn, + REPLAY_TURN_LIMIT, + replayBackgroundProjection, + replayEntry, + skillActivationFromOrigin, + toolCallFromReplayMessage, + toolResultOutput, + type ReplayRenderContext, + type SkillActivationProjection, +} from './utils/message-replay'; import { formatMcpStartupStatusSummary, mcpServerStatusKey, @@ -820,11 +844,7 @@ export class KimiTUI { return; } if (shouldReplayHistory) { - await hydrateTranscriptFromReplay( - this.state, - this.replayHydrationHooks(), - this.requireSession(), - ); + await this.hydrateTranscriptFromReplay(this.requireSession()); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { @@ -2211,7 +2231,7 @@ export class KimiTUI { } this.clearTranscriptAndRedraw(); try { - await hydrateTranscriptFromReplay(this.state, this.replayHydrationHooks(), session); + await this.hydrateTranscriptFromReplay(session); } catch (error) { const msg = formatErrorMessage(error); this.showError(`Failed to replay session history: ${msg}`); @@ -2263,6 +2283,422 @@ export class KimiTUI { this.showStatus(`Started a new session (${session.id}).`); } + // ========================================================================= + // Session Replay + // ========================================================================= + + private async hydrateTranscriptFromReplay(session: Session): Promise { + this.setAppState({ isReplaying: true }); + try { + const main = session.getResumeState()?.agents['main']; + if (main === undefined) { + this.showError('Session history is unavailable for this session.'); + return false; + } + + this.hydrateReplaySnapshot(main); + this.renderReplayRecords(main); + return true; + } catch (error) { + const message = formatErrorMessage(error); + this.showError(`Failed to replay session history: ${message}`); + return false; + } finally { + this.setAppState({ isReplaying: false }); + } + } + + private hydrateReplaySnapshot(agent: ResumedAgentState): void { + this.setAppState(appStateFromResumeAgent(agent)); + this.hydrateTodoPanelFromResume(agent); + this.hydrateBackgroundStateFromResume(agent); + } + + private hydrateTodoPanelFromResume(agent: ResumedAgentState): void { + const rawTodos = agent.toolStore?.['todo']; + if (!Array.isArray(rawTodos)) { + this.setTodoList([]); + return; + } + + const todos = rawTodos + .filter((todo): todo is TodoItem => isTodoItemShape(todo)) + .map((todo) => ({ title: todo.title, status: todo.status })); + if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { + this.setTodoList([]); + return; + } + + this.setTodoList(todos); + } + + private hydrateBackgroundStateFromResume(agent: ResumedAgentState): void { + const projection = replayBackgroundProjection(agent.background); + this.state.backgroundAgents = new Set(projection.backgroundAgents); + this.state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); + this.state.backgroundTasks = new Map( + agent.background.map((info) => [info.taskId, info]), + ); + this.state.backgroundTaskTranscriptedTerminal.clear(); + for (const info of agent.background) { + if (isTerminalBackgroundTask(info)) { + this.state.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + } + this.state.footer.setBackgroundCounts(countActiveBackgroundTasks(this.state.backgroundTasks)); + this.state.ui.requestRender(); + } + + private renderReplayRecords(agent: ResumedAgentState): void { + const context = createReplayRenderContext(); + for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) { + this.renderReplayRecord(context, record); + } + this.flushReplayAssistant(context); + this.cleanupReplayRuntime(context); + } + + private renderReplayRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { + switch (record.type) { + case 'message': + this.renderReplayMessage(context, record.message); + return; + case 'plan_updated': + this.flushReplayAssistant(context); + if (!record.enabled && context.suppressNextPlanModeOffNotice) { + context.suppressNextPlanModeOffNotice = false; + return; + } + context.suppressNextPlanModeOffNotice = false; + this.appendTranscriptEntry( + replayEntry(context, 'status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice'), + ); + return; + case 'permission_updated': + this.flushReplayAssistant(context); + this.renderReplayPermissionUpdate(context, record.mode); + return; + case 'approval_result': + this.flushReplayAssistant(context); + this.renderReplayApprovalResult(context, record.record); + return; + case 'config_updated': + return; + } + } + + private renderReplayMessage(context: ReplayRenderContext, message: ContextMessage): void { + switch (message.role) { + case 'user': + this.renderReplayUserMessage(context, message); + return; + case 'assistant': + if (message.origin?.kind === 'hook_result') { + this.renderReplayHookResult(context, message); + this.renderReplayToolCalls(context, message.toolCalls); + return; + } + collectReplayMessageContent(context.assistant, message.content); + this.flushReplayAssistant(context); + this.renderReplayToolCalls(context, message.toolCalls); + return; + case 'tool': + this.flushReplayAssistant(context); + this.renderReplayToolResult(context, message); + return; + case 'system': + return; + default: + return; + } + } + + private renderReplayUserMessage(context: ReplayRenderContext, message: ContextMessage): void { + const origin = backgroundOrigin(message); + if (origin !== undefined) { + this.flushReplayAssistant(context); + this.renderReplayBackgroundTaskNotification(context, origin); + return; + } + if (message.origin?.kind === 'hook_result') { + this.renderReplayHookResult(context, message); + return; + } + if (message.origin?.kind === 'injection') { + return; + } + + this.flushReplayAssistant(context); + const skill = skillActivationFromOrigin(message.origin); + if (skill !== undefined) { + this.renderReplaySkillActivation(context, skill); + if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') { + this.advanceReplayTurn(context); + } + return; + } + + this.advanceReplayTurn(context); + this.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), + ); + } + + private renderReplayToolCalls( + context: ReplayRenderContext, + toolCalls: readonly ToolCall[], + ): void { + if (toolCalls.length === 0) return; + context.stepIndex += 1; + this.applyReplayStepContext(context); + for (const rawToolCall of toolCalls) { + const toolCall = toolCallFromReplayMessage(rawToolCall, context); + if (toolCall === undefined) continue; + context.toolCalls.set(toolCall.id, toolCall); + this.state.activeToolCalls.set(toolCall.id, toolCall); + this.onToolCallStart(toolCall); + } + } + + private renderReplayToolResult(context: ReplayRenderContext, message: ContextMessage): void { + const toolCallId = message.toolCallId; + if (toolCallId === undefined) return; + const call = context.toolCalls.get(toolCallId); + if (call === undefined) return; + + const result: ToolResultBlockData = { + tool_call_id: toolCallId, + output: toolResultOutput(message.content), + is_error: message.isError, + }; + call.result = result; + this.applyReplayStepContext(context); + this.onToolCallEnd(toolCallId, result); + this.state.activeToolCalls.delete(toolCallId); + context.completedToolCallIds.add(toolCallId); + } + + private advanceReplayTurn(context: ReplayRenderContext): void { + context.turnIndex += 1; + context.stepIndex = 0; + context.currentTurnId = `replay:${String(context.turnIndex)}`; + this.applyReplayStepContext(context); + } + + private applyReplayStepContext(context: ReplayRenderContext): void { + this.state.currentTurnId = context.currentTurnId; + this.state.currentStep = context.stepIndex; + } + + private flushReplayAssistant(context: ReplayRenderContext): void { + const thinking = context.assistant.thinking.join(''); + const text = context.assistant.text.join(''); + context.assistant = { thinking: [], text: [] }; + this.applyReplayStepContext(context); + + if (thinking.length > 0) { + this.onThinkingUpdate(thinking); + this.onThinkingEnd(); + } + if (text.length > 0) { + this.state.assistantStreamActive = true; + this.onStreamingTextStart(); + this.onStreamingTextUpdate(text); + this.onStreamingTextEnd(); + this.state.assistantStreamActive = false; + this.state.assistantDraft = ''; + } + } + + private cleanupReplayRuntime(context: ReplayRenderContext): void { + this.flushReplayAssistant(context); + this.state.activeToolCalls.clear(); + for (const toolCallId of context.completedToolCallIds) { + this.state.pendingToolComponents.delete(toolCallId); + } + this.state.pendingAgentGroup = null; + this.state.pendingReadGroup = null; + this.state.currentTurnId = undefined; + this.state.currentStep = 0; + this.state.streamingToolCallArguments.clear(); + this.pendingToolCallFlushIds.clear(); + this.state.ui.requestRender(); + } + + private renderReplaySkillActivation( + context: ReplayRenderContext, + skill: SkillActivationProjection, + ): void { + if (context.skillActivationIds.has(skill.activationId)) return; + if (this.state.renderedSkillActivationIds.has(skill.activationId)) return; + context.skillActivationIds.add(skill.activationId); + this.state.renderedSkillActivationIds.add(skill.activationId); + this.appendTranscriptEntry({ + ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), + skillActivationId: skill.activationId, + skillName: skill.skillName, + skillArgs: skill.skillArgs, + }); + } + + private renderReplayHookResult(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'hook_result') return; + this.flushReplayAssistant(context); + this.appendTranscriptEntry( + replayEntry( + context, + 'assistant', + formatHookResultMessageForTranscript( + contentPartsToText(message.content), + message.origin.event, + message.origin.blocked === true, + ), + 'markdown', + ), + ); + } + + private renderReplayPermissionUpdate( + context: ReplayRenderContext, + mode: PermissionMode, + ): void { + if (mode === 'yolo') { + this.appendTranscriptEntry( + replayEntry(context, 'status', 'YOLO mode: ON', 'notice', { + detail: 'All actions will be approved automatically. Use with caution.', + }), + ); + return; + } + this.appendTranscriptEntry( + replayEntry( + context, + 'status', + mode === 'manual' ? 'YOLO mode: OFF' : `Permission mode: ${mode}`, + 'notice', + ), + ); + } + + private renderReplayApprovalResult( + context: ReplayRenderContext, + record: Extract['record'], + ): void { + if (record.toolName === 'ExitPlanMode') { + this.renderReplayPlanReviewResult(context, record); + return; + } + + const { result } = record; + const parts: string[] = []; + switch (result.decision) { + case 'approved': + parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${record.action}`); + if (result.feedback !== undefined && result.feedback.length > 0) { + parts.push(` — "${result.feedback}"`); + } + this.appendTranscriptEntry(replayEntry(context, 'status', parts.join(''), 'notice')); + } + + private renderReplayPlanReviewResult( + context: ReplayRenderContext, + record: Extract['record'], + ): void { + const { result } = record; + if (result.decision === 'approved') { + context.suppressNextPlanModeOffNotice = true; + return; + } + this.removeReplayToolCall(record.toolCallId); + + let content: string; + switch (result.decision) { + case 'rejected': + content = + result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; + break; + case 'cancelled': + content = 'Plan review cancelled'; + break; + } + const detail = + result.feedback !== undefined && result.feedback.length > 0 + ? `Feedback: ${result.feedback}` + : undefined; + this.appendTranscriptEntry(replayEntry(context, 'status', content, 'notice', { detail })); + } + + private removeReplayToolCall(toolCallId: string): void { + this.state.activeToolCalls.delete(toolCallId); + this.state.pendingToolComponents.delete(toolCallId); + const index = this.state.transcriptEntries.findIndex( + (entry) => entry.toolCallData?.id === toolCallId, + ); + if (index >= 0) this.state.transcriptEntries.splice(index, 1); + const children = this.state.transcriptContainer.children; + const childIndex = children.findIndex( + (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, + ); + if (childIndex >= 0) { + children.splice(childIndex, 1); + this.state.transcriptContainer.invalidate(); + } + } + + private renderReplayBackgroundTaskNotification( + context: ReplayRenderContext, + origin: Extract, + ): void { + const task = this.state.backgroundTasks.get(origin.taskId); + if (task !== undefined && task.taskId.startsWith('bash-')) { + const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); + this.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + this.state.backgroundTaskTranscriptedTerminal.add(origin.taskId); + return; + } + + const meta: BackgroundAgentMetadata = { + agentId: origin.taskId, + parentToolCallId: origin.taskId, + description: task?.description, + }; + let status = formatBackgroundAgentTranscript( + origin.status === 'completed' ? 'completed' : 'failed', + meta, + ); + if (origin.status === 'lost') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' lost in background'), + }; + } else if (origin.status === 'killed') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' stopped'), + }; + } + this.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + this.state.backgroundAgents.delete(meta.agentId); + this.state.backgroundAgentMetadata.delete(meta.agentId); + } + // ========================================================================= // Session Events // ========================================================================= @@ -3613,6 +4049,7 @@ export class KimiTUI { // Appends an approval-result entry to the transcript. private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { + if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; const parts: string[] = []; switch (response.decision) { case 'approved': @@ -3743,24 +4180,6 @@ export class KimiTUI { return this.showLoginProgressSpinner('Waiting for authorization…'); } - // Provides UI callbacks used while hydrating transcript history. - private replayHydrationHooks(): ReplayHydrationHooks { - return { - setAppState: (patch) => { - this.setAppState(patch); - }, - appendEntry: (entry) => { - this.appendTranscriptEntry(entry); - }, - setTodoList: (todos) => { - this.setTodoList(todos); - }, - emitError: (message) => { - this.showError(message); - }, - }; - } - // ========================================================================= // Panes / Presentation State // ========================================================================= @@ -5739,20 +6158,3 @@ export class KimiTUI { }); } } - -function formatHookResultMarkdown(event: HookResultEvent): string { - return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`; -} - -function formatHookResultPlain(event: HookResultEvent): string { - return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; -} - -function formatHookResultTitle(event: HookResultEvent): string { - return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; -} - -function formatHookResultBody(event: HookResultEvent): string { - const content = event.content.trim(); - return content.length === 0 ? '(empty)' : content; -} diff --git a/apps/kimi-code/src/tui/utils/hook-result-format.ts b/apps/kimi-code/src/tui/utils/hook-result-format.ts new file mode 100644 index 000000000..758e3e912 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/hook-result-format.ts @@ -0,0 +1,18 @@ +import type { HookResultEvent } from '@moonshot-ai/kimi-code-sdk'; + +export function formatHookResultMarkdown(event: HookResultEvent): string { + return `*${formatHookResultTitle(event)}*\n\n${formatHookResultBody(event)}`; +} + +export function formatHookResultPlain(event: HookResultEvent): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEvent): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEvent): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts new file mode 100644 index 000000000..5328d6224 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -0,0 +1,294 @@ +import type { + AgentReplayRecord, + BackgroundTaskInfo, + ContentPart, + ContextMessage, + PromptOrigin, + ResumedAgentState, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; + +import type { + AppState, + BackgroundAgentMetadata, + ToolCallBlockData, + TranscriptEntry, +} from '#/tui/types'; + +import { mediaUrlPartToText } from './media-url'; +import { nextTranscriptId } from './transcript-id'; + +export const REPLAY_TURN_LIMIT = 10; + +export interface ReplayRenderContext { + turnIndex: number; + stepIndex: number; + currentTurnId: string | undefined; + assistant: { + thinking: string[]; + text: string[]; + }; + toolCalls: Map; + completedToolCallIds: Set; + skillActivationIds: Set; + suppressNextPlanModeOffNotice: boolean; +} + +export interface SkillActivationProjection { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; +} + +export interface ReplayBackgroundProjection { + readonly backgroundAgents: ReadonlySet; + readonly backgroundAgentMetadata: ReadonlyMap; +} + +export function appStateFromResumeAgent(agent: ResumedAgentState): Partial { + const maxContextTokens = agent.config.modelCapabilities?.max_context_tokens ?? 0; + const contextTokens = agent.context.tokenCount; + const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + return { + model: agent.config.modelAlias ?? agent.config.provider?.model ?? '', + contextTokens, + maxContextTokens, + contextUsage, + planMode: agent.plan !== null, + yolo: agent.permission.mode === 'yolo', + permissionMode: agent.permission.mode, + }; +} + +export function isTerminalBackgroundTask(info: BackgroundTaskInfo): boolean { + return ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost' + ); +} + +export function countActiveBackgroundTasks(tasks: ReadonlyMap): { + bashTasks: number; + agentTasks: number; +} { + let bashTasks = 0; + let agentTasks = 0; + for (const info of tasks.values()) { + if (isTerminalBackgroundTask(info)) continue; + if (info.taskId.startsWith('agent-')) { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + return { bashTasks, agentTasks }; +} + +export function replayBackgroundProjection( + background: readonly BackgroundTaskInfo[], +): ReplayBackgroundProjection { + const backgroundAgents = new Set(); + const backgroundAgentMetadata = new Map(); + for (const info of background) { + if (!info.taskId.startsWith('agent-')) continue; + if (isTerminalBackgroundTask(info)) continue; + backgroundAgents.add(info.taskId); + backgroundAgentMetadata.set(info.taskId, { + agentId: info.taskId, + parentToolCallId: info.taskId, + description: info.description, + }); + } + return { backgroundAgents, backgroundAgentMetadata }; +} + +export function createReplayRenderContext(): ReplayRenderContext { + return { + turnIndex: 0, + stepIndex: 0, + currentTurnId: undefined, + assistant: { thinking: [], text: [] }, + toolCalls: new Map(), + completedToolCallIds: new Set(), + skillActivationIds: new Set(), + suppressNextPlanModeOffNotice: false, + }; +} + +export function limitReplayRecordsByTurn( + records: readonly AgentReplayRecord[], + maxTurns: number, +): readonly AgentReplayRecord[] { + if (maxTurns <= 0) return []; + const turnStarts = records.flatMap((record, index) => + isReplayUserTurnRecord(record) ? [index] : [], + ); + if (turnStarts.length <= maxTurns) return records; + return records.slice(turnStarts[turnStarts.length - maxTurns]); +} + +export function replayEntry( + context: ReplayRenderContext, + kind: TranscriptEntry['kind'], + content: string, + renderMode: TranscriptEntry['renderMode'], + extras: { detail?: string } = {}, +): TranscriptEntry { + return { + id: nextTranscriptId(), + kind, + turnId: context.currentTurnId, + renderMode, + content, + detail: extras.detail, + }; +} + +export function collectReplayMessageContent( + target: ReplayRenderContext['assistant'], + content: readonly ContentPart[], +): void { + for (const part of content) { + switch (part.type) { + case 'think': + target.thinking.push(part.think); + break; + case 'text': + target.text.push(part.text); + break; + case 'audio_url': + case 'image_url': + case 'video_url': + break; + } + } +} + +export function toolCallFromReplayMessage( + rawToolCall: ToolCall, + context: ReplayRenderContext, +): ToolCallBlockData | undefined { + const id = rawToolCall.id; + const name = rawToolCall.name; + if (id.length === 0 || name.length === 0) return undefined; + return { + id, + name, + args: parseReplayToolArguments(rawToolCall.arguments), + step: context.stepIndex, + turnId: context.currentTurnId, + }; +} + +export function toolResultOutput(content: readonly ContentPart[]): string { + if (content.some((part) => part.type !== 'text')) { + return JSON.stringify(content); + } + return contentPartsToText(content); +} + +export function contentPartsToText(content: readonly ContentPart[]): string { + return content.map(contentPartToText).join(''); +} + +export function backgroundOrigin( + message: ContextMessage, +): Extract | undefined { + return message.origin?.kind === 'background_task' ? message.origin : undefined; +} + +export function skillActivationFromOrigin( + origin: PromptOrigin | undefined, +): SkillActivationProjection | undefined { + if (origin?.kind !== 'skill_activation') return undefined; + return { + activationId: origin.activationId, + skillName: origin.skillName, + skillArgs: origin.skillArgs, + }; +} + +export function formatHookResultMessageForTranscript( + text: string, + fallbackEvent: string, + blocked: boolean, +): string { + const results: Array<{ event: string; body: string }> = []; + let lastIndex = 0; + + for (const match of text.matchAll(HOOK_RESULT_RE)) { + if (text.slice(lastIndex, match.index).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + const event = match[1]; + const body = match[2]; + if (event === undefined || body === undefined) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + results.push({ event, body }); + lastIndex = match.index + match[0].length; + } + + if (results.length === 0 || text.slice(lastIndex).trim().length > 0) { + return formatHookResultBlock(fallbackEvent, text, blocked); + } + + return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n'); +} + +function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { + if (record.type !== 'message') return false; + const { message } = record; + if (message.role !== 'user') return false; + switch (message.origin?.kind) { + case undefined: + case 'user': + return true; + case 'skill_activation': + return message.origin.trigger === 'user-slash'; + case 'background_task': + case 'compaction_summary': + case 'hook_result': + case 'injection': + case 'system_trigger': + return false; + } +} + +function parseReplayToolArguments(value: string | null): Record { + if (value === null || value.length === 0) return {}; + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function contentPartToText(part: ContentPart): string { + switch (part.type) { + case 'text': + return part.text; + case 'think': + return part.think; + case 'image_url': + return mediaUrlPartToText('image', part.imageUrl.url); + case 'video_url': + return mediaUrlPartToText('video', part.videoUrl.url); + case 'audio_url': + return mediaUrlPartToText('audio', part.audioUrl.url); + } +} + +const HOOK_RESULT_RE = + /\n?([\s\S]*?)\n?<\/hook_result>/g; + +function formatHookResultBlock(event: string, body: string, blocked: boolean): string { + return `*${event} hook${blocked ? ' blocked' : ''}*\n\n${body.trim() || '(empty)'}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts b/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts deleted file mode 100644 index 5c4a7618c..000000000 --- a/apps/kimi-code/test/tui/actions/replay-hydrate-agent-group.test.ts +++ /dev/null @@ -1,415 +0,0 @@ - -import { Text } from '@earendil-works/pi-tui'; -import type { Session } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; - -import { hydrateProjectedEntries, hydrateTranscriptFromReplay } from '#/tui/actions/replay-ops'; -import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; -import { ToolCallComponent } from '#/tui/components/messages/tool-call'; -import { createTUIState, type KimiTUIOptions, type TUIState } from '#/tui/kimi-tui'; -import type { AppState, ToolCallBlockData, TranscriptEntry } from '#/tui/types'; - -function makeAppState(): AppState { - return { - model: 'k2', - workDir: '/tmp/proj-a', - sessionId: 'sess-1', - yolo: false, - permissionMode: 'manual', - planMode: false, - thinking: false, - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 100, - isStreaming: false, - isCompacting: false, - isReplaying: false, - streamingPhase: 'idle', - streamingStartTime: 0, - theme: 'dark', - version: '0.0.0-test', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - availableModels: {}, - availableProviders: {}, - sessionTitle: null, - }; -} - -function makeTuiState(): TUIState { - const options: KimiTUIOptions = { - initialAppState: makeAppState(), - startup: { - continueLast: false, - yolo: false, - plan: false, - }, - resolvedTheme: 'dark', - }; - const state = createTUIState(options); - vi.spyOn(state.ui, 'requestRender').mockImplementation(() => {}); - return state; -} - -function appendEntry(state: TUIState, entry: TranscriptEntry): void { - state.transcriptEntries.push(entry); - if (entry.toolCallData !== undefined) { - const tc = new ToolCallComponent( - entry.toolCallData, - entry.toolCallData.result, - state.theme.colors, - state.ui, - state.theme.markdownTheme, - ); - state.pendingToolComponents.set(entry.toolCallData.id, tc); - state.transcriptContainer.addChild(tc); - return; - } - state.transcriptContainer.addChild(new Text(entry.content, 0, 0)); -} - -function hydrate(state: TUIState, entries: readonly TranscriptEntry[]): void { - hydrateProjectedEntries(state, entries, (entry) => { - appendEntry(state, entry); - }); -} - -function setTodoList( - state: TUIState, - todos: Parameters[0], -): void { - state.todoPanel.setTodos(todos); - state.todoPanelContainer.clear(); - if (!state.todoPanel.isEmpty()) { - state.todoPanelContainer.addChild(state.todoPanel); - } -} - -function sessionWithToolStore(toolStore: Record): Session { - return { - getResumeState: () => ({ - sessionMetadata: {}, - agents: { - main: { - type: 'main', - config: { - modelAlias: 'k2', - provider: undefined, - modelCapabilities: { max_context_tokens: 100 }, - }, - context: { history: [], tokenCount: 0 }, - replay: [], - permission: { mode: 'manual' }, - plan: null, - usage: {}, - tools: [], - toolStore, - background: [], - }, - }, - }), - } as unknown as Session; -} - -let entryIdSeq = 0; -function makeAgentEntry( - id: string, - step: number, - turnId: string, - result?: { is_error?: boolean }, -): TranscriptEntry { - entryIdSeq += 1; - const tc: ToolCallBlockData = { - id, - name: 'Agent', - args: { description: id }, - step, - turnId, - ...(result !== undefined - ? { result: { tool_call_id: id, output: 'done', is_error: result.is_error ?? false } } - : {}), - }; - return { - id: `e${String(entryIdSeq)}`, - kind: 'tool_call', - turnId, - renderMode: 'plain', - content: '', - toolCallData: tc, - }; -} - -function makeBashEntry(id: string, step: number, turnId: string): TranscriptEntry { - entryIdSeq += 1; - const tc: ToolCallBlockData = { - id, - name: 'Bash', - args: { command: 'pwd' }, - step, - turnId, - }; - return { - id: `e${String(entryIdSeq)}`, - kind: 'tool_call', - turnId, - renderMode: 'plain', - content: '', - toolCallData: tc, - }; -} - -function makeUserEntry(turnId: string, content: string): TranscriptEntry { - entryIdSeq += 1; - return { - id: `e${String(entryIdSeq)}`, - kind: 'user', - turnId, - renderMode: 'plain', - content, - }; -} - -describe('hydrateProjectedEntries', () => { - it('hydrates the visible todo panel from resumed tool store state', async () => { - const state = makeTuiState(); - const errors: string[] = []; - - const ok = await hydrateTranscriptFromReplay( - state, - { - setAppState: (patch) => { - state.appState = { ...state.appState, ...patch }; - }, - appendEntry: (entry) => { - appendEntry(state, entry); - }, - setTodoList: (todos) => { - setTodoList(state, todos); - }, - emitError: (message) => { - errors.push(message); - }, - }, - sessionWithToolStore({ - todo: [ - { title: 'Review resume snapshot', status: 'done' }, - { title: 'Render todo panel', status: 'in_progress' }, - { title: '', status: 'pending' }, - ], - }), - ); - - expect(ok).toBe(true); - expect(errors).toEqual([]); - expect(state.todoPanel.getTodos()).toEqual([ - { title: 'Review resume snapshot', status: 'done' }, - { title: 'Render todo panel', status: 'in_progress' }, - ]); - expect(state.todoPanelContainer.children).toContain(state.todoPanel); - }); - - it('clears the todo panel when resumed state has no todo store entry', async () => { - const state = makeTuiState(); - setTodoList(state, [{ title: 'stale todo', status: 'pending' }]); - - const ok = await hydrateTranscriptFromReplay( - state, - { - setAppState: (patch) => { - state.appState = { ...state.appState, ...patch }; - }, - appendEntry: (entry) => { - appendEntry(state, entry); - }, - setTodoList: (todos) => { - setTodoList(state, todos); - }, - emitError: () => {}, - }, - sessionWithToolStore({}), - ); - - expect(ok).toBe(true); - expect(state.todoPanel.getTodos()).toEqual([]); - expect(state.todoPanelContainer.children).not.toContain(state.todoPanel); - }); - - it('groups 2 adjacent same-step Agents into a single AgentGroupComponent', () => { - const state = makeTuiState(); - const entries: TranscriptEntry[] = [ - makeAgentEntry('a1', 1, 't1', { is_error: false }), - makeAgentEntry('a2', 1, 't1', { is_error: false }), - ]; - - hydrate(state, entries); - - const children = state.transcriptContainer.children; - expect(children.length).toBe(1); - expect(children[0]).toBeInstanceOf(AgentGroupComponent); - expect((children[0] as AgentGroupComponent).size()).toBe(2); - // ToolCallComponent still registers in pendingToolComponents because - // later wire event routing depends on this mapping. - expect(state.pendingToolComponents.has('a1')).toBe(true); - expect(state.pendingToolComponents.has('a2')).toBe(true); - }); - - it('keeps cross-step Agents independent', () => { - const state = makeTuiState(); - const entries: TranscriptEntry[] = [ - makeAgentEntry('a1', 1, 't1'), - makeAgentEntry('a2', 2, 't1'), - ]; - - hydrate(state, entries); - const children = state.transcriptContainer.children; - expect(children.length).toBe(2); - expect(children[0]).toBeInstanceOf(ToolCallComponent); - expect(children[1]).toBeInstanceOf(ToolCallComponent); - }); - - it('does not group when a non-Agent tool sits between Agents', () => { - const state = makeTuiState(); - const entries: TranscriptEntry[] = [ - makeAgentEntry('a1', 1, 't1'), - makeBashEntry('b1', 1, 't1'), - makeAgentEntry('a2', 1, 't1'), - ]; - - hydrate(state, entries); - const children = state.transcriptContainer.children; - expect(children.length).toBe(3); - for (const child of children) expect(child).toBeInstanceOf(ToolCallComponent); - }); - - it('a single Agent is left as a standalone ToolCallComponent', () => { - const state = makeTuiState(); - hydrate(state, [makeAgentEntry('a1', 1, 't1')]); - const children = state.transcriptContainer.children; - expect(children.length).toBe(1); - expect(children[0]).toBeInstanceOf(ToolCallComponent); - }); - - it('user message between Agents prevents grouping', () => { - const state = makeTuiState(); - const entries: TranscriptEntry[] = [ - makeAgentEntry('a1', 1, 't1'), - makeUserEntry('t1', 'hello'), - makeAgentEntry('a2', 1, 't1'), - ]; - - hydrate(state, entries); - const children = state.transcriptContainer.children; - expect(children.length).toBe(3); - expect(children[0]).toBeInstanceOf(ToolCallComponent); - expect(children[2]).toBeInstanceOf(ToolCallComponent); - }); - - it('replay does not write pendingAgentGroup (it stays null after hydration)', () => { - const state = makeTuiState(); - hydrate(state, [makeAgentEntry('a1', 1, 't1'), makeAgentEntry('a2', 1, 't1')]); - expect(state.pendingAgentGroup).toBeNull(); - }); - - it('background Agent replay hydrates only background status rows, not AgentGroupComponent', () => { - const state = makeTuiState(); - hydrate(state, [ - { - id: 'e-background-started', - kind: 'status', - renderMode: 'plain', - content: 'explore agent started in background', - }, - { - id: 'e-background-completed', - kind: 'status', - renderMode: 'plain', - content: 'explore agent completed in background', - }, - ]); - - expect( - state.transcriptContainer.children.some((child) => child instanceof AgentGroupComponent), - ).toBe(false); - expect( - state.transcriptContainer.children.some((child) => child instanceof ToolCallComponent), - ).toBe(false); - }); - - it('group with mixed completed/failed children renders correct phase tails', () => { - const state = makeTuiState(); - hydrate(state, [ - makeAgentEntry('a1', 1, 't1', { is_error: false }), - makeAgentEntry('a2', 1, 't1', { is_error: true }), - ]); - const group = state.transcriptContainer.children[0] as AgentGroupComponent; - const out = group - .render(120) - .join('\n') - .replaceAll(/\[[0-9;]*m/g, ''); - expect(out).toContain('✓ Completed'); - expect(out).toContain('✗ Failed'); - }); -}); - -describe('hydrateTodoPanelFromResume', () => { - function makeHooks(state: TUIState) { - return { - setAppState: vi.fn(), - appendEntry: vi.fn(), - setTodoList: (todos: Parameters[1]) => setTodoList(state, todos), - emitError: vi.fn(), - }; - } - - it('clears the panel when all resumed todos are done', async () => { - const state = makeTuiState(); - const session = sessionWithToolStore({ - todo: [ - { title: 'A', status: 'done' }, - { title: 'B', status: 'done' }, - ], - }); - await hydrateTranscriptFromReplay(state, makeHooks(state), session); - expect(state.todoPanel.isEmpty()).toBe(true); - expect(state.todoPanelContainer.children.length).toBe(0); - }); - - it('restores pending and in-progress todos', async () => { - const state = makeTuiState(); - const session = sessionWithToolStore({ - todo: [ - { title: 'A', status: 'done' }, - { title: 'B', status: 'in_progress' }, - ], - }); - await hydrateTranscriptFromReplay(state, makeHooks(state), session); - expect(state.todoPanel.getTodos()).toEqual([ - { title: 'A', status: 'done' }, - { title: 'B', status: 'in_progress' }, - ]); - expect(state.todoPanelContainer.children.length).toBe(1); - }); - - it('clears the panel when the tool store has no todo key', async () => { - const state = makeTuiState(); - const session = sessionWithToolStore({}); - await hydrateTranscriptFromReplay(state, makeHooks(state), session); - expect(state.todoPanel.isEmpty()).toBe(true); - expect(state.todoPanelContainer.children.length).toBe(0); - }); - - it('filters out malformed todo items', async () => { - const state = makeTuiState(); - const session = sessionWithToolStore({ - todo: [ - { title: 'Valid', status: 'pending' }, - { title: '', status: 'done' }, - { status: 'in_progress' }, - 'not-an-object', - ], - }); - await hydrateTranscriptFromReplay(state, makeHooks(state), session); - expect(state.todoPanel.getTodos()).toEqual([{ title: 'Valid', status: 'pending' }]); - expect(state.todoPanelContainer.children.length).toBe(1); - }); -}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 68d299d83..7e803a182 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -311,12 +311,12 @@ describe('ToolCallComponent', () => { expect(header).toContain('Current plan · Approved: Pragmatic refactor'); }); - it('header chips Rejected and renders feedback for reject-with-suggestion', () => { + it('renders Rejected in the plan box title and keeps revise feedback visible', () => { const component = new ToolCallComponent( { id: 'call_exit_reject_fb', name: 'ExitPlanMode', - args: {}, + args: { plan: '# Rework Plan\n\n- step 1' }, }, { tool_call_id: 'call_exit_reject_fb', @@ -324,31 +324,37 @@ describe('ToolCallComponent', () => { is_error: false, }, darkColors, + undefined, + createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Current plan · Rejected'); + expect(out).toContain('plan · Rejected'); expect(out).toContain('↪ Suggestion'); expect(out).toContain('please rethink step 2'); }); - it('header chips Rejected without feedback when user rejected outright', () => { + it('renders is_error ExitPlanMode reject in the plan box title without raw error text', () => { const component = new ToolCallComponent( { id: 'call_exit_reject', name: 'ExitPlanMode', - args: {}, + args: { plan: '# Rejected Plan\n\n- keep investigating' }, }, { tool_call_id: 'call_exit_reject', output: 'Plan rejected by user. Plan mode remains active.', - is_error: false, + is_error: true, }, darkColors, + undefined, + createMarkdownTheme(darkColors), ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Current plan · Rejected'); + expect(out).toContain('plan · Rejected'); + expect(out).toContain('Rejected Plan'); + expect(out).not.toContain('Plan rejected by user.'); expect(out).not.toContain('Plan mode remains active.'); }); diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 04f1f84bb..9b35a481f 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -40,6 +40,32 @@ describe('PlanBoxComponent', () => { expect(top).not.toContain('…/'); }); + it('renders a status chip in the top border', () => { + const box = new PlanBoxComponent('# Hello', theme, darkColors.success, undefined, { + status: { label: 'Rejected', colorHex: darkColors.error }, + }); + const out = strip(box.render(60).join('\n')); + const top = out.split('\n')[0]!; + expect(top).toContain(' plan · Rejected '); + }); + + it('keeps path status title to the basename without leaking directories', () => { + const box = new PlanBoxComponent( + '# Hello', + theme, + darkColors.success, + '/tmp/projects/foo/.kimi-code/plans/rejected-plan.md', + { + status: { label: 'Rejected', colorHex: darkColors.error }, + }, + ); + const out = strip(box.render(80).join('\n')); + const top = out.split('\n')[0]!; + expect(top).toContain(' plan: rejected-plan.md · Rejected '); + expect(top).not.toContain('/tmp/'); + expect(top).not.toContain('…/'); + }); + it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); const top = box.render(60)[0]!; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 8ca3132be..7ba1a26a1 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -10,6 +10,7 @@ import { import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { QueuedMessage } from '#/tui/types'; @@ -17,8 +18,13 @@ import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + function stripSgr(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } interface MessageDriver { @@ -1105,6 +1111,82 @@ describe('KimiTUI message flow', () => { }); }); + it('shows plan review reject on the plan card without an approval notice', async () => { + const planContent = '# Reject Plan\n\n- keep this plan visible after reject'; + const session = makeSession({ + getPlan: vi.fn(async () => ({ + id: 'reject-plan', + content: planContent, + path: '/tmp/reject-plan.md', + })), + }); + const { driver } = await makeDriver(session); + + driver.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_exit_reject_plan', + name: 'ExitPlanMode', + args: {}, + } as Event, + vi.fn(), + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Reject Plan'); + expect(countOccurrences(transcript, 'keep this plan visible after reject')).toBe(1); + }); + + const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as + | ((request: ApprovalRequest) => Promise) + | undefined; + if (approvalHandler === undefined) throw new Error('expected approval handler'); + const response = approvalHandler({ + turnId: 1, + toolCallId: 'call_exit_reject_plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: planContent, + path: '/tmp/reject-plan.md', + }, + }); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); + }); + (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('2'); + await expect(response).resolves.toMatchObject({ decision: 'rejected' }); + + driver.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_exit_reject_plan', + output: 'Plan rejected by user. Plan mode remains active.', + isError: true, + } as Event, + vi.fn(), + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('plan: reject-plan.md · Rejected'); + expect(transcript).toContain('Reject Plan'); + expect(countOccurrences(transcript, 'keep this plan visible after reject')).toBe(1); + expect(transcript).not.toContain('Rejected: Review plan'); + expect(transcript).not.toContain('Plan rejected by user.'); + expect(transcript).not.toContain('Plan mode remains active.'); + }); + }); + it('renders /status using the active session runtime status', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts new file mode 100644 index 000000000..c21c8af3e --- /dev/null +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -0,0 +1,501 @@ +import type { + AgentReplayRecord, + BackgroundTaskInfo, + ContentPart, + PromptOrigin, + ResumedAgentState, + Role, + Session, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ReadGroupComponent } from '#/tui/components/messages/read-group'; + +vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); + +interface ReplayDriver { + readonly state: TUIState; + init(): Promise; + switchToSession(session: Session, statusMessage: string): Promise; +} + +function makeStartupInput(): KimiTUIStartupInput { + return { + cliOptions: { + session: undefined, + continue: false, + yolo: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + tuiConfig: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }, + version: '0.0.0-test', + workDir: '/tmp/proj-a', + resolvedTheme: 'dark', + }; +} + +function message( + role: Role, + content: readonly ContentPart[], + extra: { + readonly toolCalls?: readonly ToolCall[]; + readonly toolCallId?: string; + readonly origin?: PromptOrigin; + readonly isError?: boolean; + } = {}, +): AgentReplayRecord { + return { + type: 'message', + message: { + role, + content: [...content], + toolCalls: [...(extra.toolCalls ?? [])], + toolCallId: extra.toolCallId, + origin: extra.origin, + isError: extra.isError, + }, + }; +} + +function toolCall(id: string, name: string, args: Record): ToolCall { + return { + type: 'function', + id, + name, + arguments: JSON.stringify(args), + }; +} + +function baseAgentState( + replay: readonly AgentReplayRecord[], + overrides: Partial = {}, +): ResumedAgentState { + return { + type: 'main', + config: { + cwd: '/tmp/proj-a', + modelAlias: 'k2', + provider: undefined, + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + max_context_tokens: 100, + }, + thinkingLevel: 'off', + systemPrompt: '', + }, + context: { history: [], tokenCount: 0 }, + replay, + permission: { mode: 'manual', rules: [] }, + plan: null, + usage: {}, + tools: [], + toolStore: {}, + background: [], + ...overrides, + }; +} + +function makeSession( + replay: readonly AgentReplayRecord[], + overrides: Partial = {}, +): Session { + const agent = baseAgentState(replay, overrides); + return { + id: 'ses-replay', + model: 'k2', + summary: { title: null }, + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingLevel: 'off', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + setModel: vi.fn(async () => {}), + setThinking: vi.fn(async () => {}), + setPermission: vi.fn(async () => {}), + setPlanMode: vi.fn(async () => {}), + onEvent: vi.fn(() => vi.fn()), + listMcpServers: vi.fn(async () => []), + listSkills: vi.fn(async () => []), + getResumeState: vi.fn(() => ({ + sessionMetadata: {}, + agents: { main: agent }, + })), + close: vi.fn(async () => {}), + } as unknown as Session; +} + +function makeHarness(initialSession: Session) { + return { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + setConfig: vi.fn(async () => ({ providers: {} })), + createSession: vi.fn(async () => initialSession), + resumeSession: vi.fn(async () => initialSession), + forkSession: vi.fn(async () => initialSession), + listSessions: vi.fn(async () => []), + close: vi.fn(async () => {}), + track: vi.fn(), + setTelemetryContext: vi.fn(), + interactiveAgentId: 'main', + auth: { + status: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getManagedUsage: vi.fn(), + submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + }, + }; +} + +async function makeDriver(initialSession: Session): Promise { + const driver = new KimiTUI( + makeHarness(initialSession) as never, + makeStartupInput(), + ) as unknown as ReplayDriver; + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + await driver.init(); + return driver; +} + +async function replayIntoDriver( + replay: readonly AgentReplayRecord[], + overrides: Partial = {}, +): Promise { + const initial = makeSession([]); + const resumed = makeSession(replay, overrides); + const driver = await makeDriver(initial); + await driver.switchToSession(resumed, 'Resumed session (ses-replay).'); + return driver; +} + +function backgroundTask( + taskId: string, + description: string, + status: BackgroundTaskInfo['status'] = 'running', +): BackgroundTaskInfo { + return { + taskId, + command: `[agent] ${description}`, + description, + status, + pid: 0, + exitCode: status === 'completed' ? 0 : null, + startedAt: 1, + endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + }; +} + +describe('KimiTUI resume message replay', () => { + it('groups replayed Agent calls from one assistant message using live grouping', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'run two agents' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_agent_1', 'Agent', { + description: 'Review API', + subagent_type: 'reviewer', + }), + toolCall('call_agent_2', 'Agent', { + description: 'Review tests', + subagent_type: 'reviewer', + }), + ], + }), + message('tool', [{ type: 'text', text: 'agent one done' }], { + toolCallId: 'call_agent_1', + }), + message('tool', [{ type: 'text', text: 'agent two done' }], { + toolCallId: 'call_agent_2', + }), + ]; + + const driver = await replayIntoDriver(replay); + const group = driver.state.transcriptContainer.children.find( + (child) => child instanceof AgentGroupComponent, + ); + + expect(group).toBeInstanceOf(AgentGroupComponent); + expect((group as AgentGroupComponent).size()).toBe(2); + expect(driver.state.pendingAgentGroup).toBeNull(); + expect(driver.state.pendingToolComponents.has('call_agent_1')).toBe(false); + expect(driver.state.pendingToolComponents.has('call_agent_2')).toBe(false); + }); + + it('groups replayed Read calls from one assistant message using live grouping', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'read files' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_read_1', 'Read', { file_path: '/tmp/proj-a/src/a.ts' }), + toolCall('call_read_2', 'Read', { file_path: '/tmp/proj-a/src/b.ts' }), + ], + }), + message('tool', [{ type: 'text', text: 'line a\nline b\n' }], { + toolCallId: 'call_read_1', + }), + message('tool', [{ type: 'text', text: 'line c\n' }], { + toolCallId: 'call_read_2', + }), + ]; + + const driver = await replayIntoDriver(replay); + const group = driver.state.transcriptContainer.children.find( + (child) => child instanceof ReadGroupComponent, + ); + + expect(group).toBeInstanceOf(ReadGroupComponent); + expect((group as ReadGroupComponent).size()).toBe(2); + expect(driver.state.pendingReadGroup).toBeNull(); + expect(driver.state.pendingToolComponents.has('call_read_1')).toBe(false); + expect(driver.state.pendingToolComponents.has('call_read_2')).toBe(false); + }); + + it('hydrates todo and background snapshot state from resumed main agent', async () => { + const driver = await replayIntoDriver([], { + toolStore: { + todo: [ + { title: 'Review resume snapshot', status: 'done' }, + { title: 'Render replay transcript', status: 'in_progress' }, + { title: '', status: 'pending' }, + ], + }, + background: [ + backgroundTask('agent-bg1', 'Review long-running work', 'running'), + backgroundTask('bash-bg1', 'Build package', 'completed'), + ], + }); + + expect(driver.state.todoPanel.getTodos()).toEqual([ + { title: 'Review resume snapshot', status: 'done' }, + { title: 'Render replay transcript', status: 'in_progress' }, + ]); + expect(driver.state.backgroundTasks.has('agent-bg1')).toBe(true); + expect(driver.state.backgroundTasks.has('bash-bg1')).toBe(true); + expect(driver.state.backgroundTaskTranscriptedTerminal.has('bash-bg1')).toBe(true); + }); + + it('renders replayed bash background notifications as bash tasks', async () => { + const driver = await replayIntoDriver( + [ + message('user', [{ type: 'text', text: 'Background task lost.' }], { + origin: { + kind: 'background_task', + taskId: 'bash-lost0000', + status: 'lost', + notificationId: 'task:bash-lost0000:lost', + }, + }), + ], + { + background: [backgroundTask('bash-lost0000', 'Background timestamp logger', 'lost')], + }, + ); + + const status = driver.state.transcriptEntries.find( + (entry) => entry.backgroundAgentStatus !== undefined, + ); + + expect(status?.backgroundAgentStatus?.headline).toBe('bash task lost'); + expect(status?.backgroundAgentStatus?.detail).toContain('Background timestamp logger'); + expect(status?.backgroundAgentStatus?.headline).not.toContain('agent'); + }); + + it('renders only the most recent ten visible user turns', async () => { + const replay = Array.from({ length: 12 }, (_, index) => [ + message('user', [{ type: 'text', text: `prompt ${index}` }]), + message('assistant', [{ type: 'text', text: `answer ${index}` }]), + ]).flat(); + + const driver = await replayIntoDriver(replay); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'user') + .map((entry) => entry.content), + ).toEqual([ + 'prompt 2', + 'prompt 3', + 'prompt 4', + 'prompt 5', + 'prompt 6', + 'prompt 7', + 'prompt 8', + 'prompt 9', + 'prompt 10', + 'prompt 11', + ]); + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'assistant') + .map((entry) => entry.content), + ).toEqual([ + 'answer 2', + 'answer 3', + 'answer 4', + 'answer 5', + 'answer 6', + 'answer 7', + 'answer 8', + 'answer 9', + 'answer 10', + 'answer 11', + ]); + }); + + it('renders user-slash skill activation once without exposing injected prompt text', async () => { + const activation = message( + 'user', + [{ type: 'text', text: 'Review the requested file.\n\nUser request:\nsrc/app.ts' }], + { + origin: { + kind: 'skill_activation', + activationId: 'act-review', + skillName: 'review', + skillArgs: 'src/app.ts', + trigger: 'user-slash', + }, + }, + ); + + const driver = await replayIntoDriver([activation, activation]); + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + + expect(transcript).toContain('review'); + expect(transcript).toContain('src/app.ts'); + expect(transcript).not.toContain('Review the requested file'); + expect(driver.state.renderedSkillActivationIds.has('act-review')).toBe(true); + }); + + it('renders replayed hook results as assistant transcript entries', async () => { + const hookResult = + '\nhook response 1\n\n' + + '\nhook response 2\n'; + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt' }]), + message('user', [{ type: 'text', text: hookResult }], { + origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, + }), + ]); + + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + + expect(transcript).toContain('UserPromptSubmit hook'); + expect(transcript).toContain('hook response 1'); + expect(transcript).toContain('hook response 2'); + }); + + it('renders plan permission and approval replay notices', async () => { + const driver = await replayIntoDriver([ + { type: 'plan_updated', enabled: true }, + { type: 'permission_updated', mode: 'auto' }, + { type: 'permission_updated', mode: 'yolo' }, + { type: 'permission_updated', mode: 'manual' }, + { + type: 'approval_result', + record: { + turnId: 0, + toolCallId: 'call_bash', + action: 'run command', + toolName: 'Bash', + result: { + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }, + }, + }, + { type: 'plan_updated', enabled: false }, + ]); + + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + + expect(transcript).toContain('Plan mode: ON'); + expect(transcript).toContain('Permission mode: auto'); + expect(transcript).toContain('YOLO mode: ON'); + expect(transcript).toContain('YOLO mode: OFF'); + expect(transcript).toContain('Approved for session: run command'); + expect(transcript).toContain('Plan mode: OFF'); + }); + + it('keeps only the final approved plan card after rejected plan reviews', async () => { + const driver = await replayIntoDriver([ + message('assistant', [], { + toolCalls: [toolCall('call_exit_reject', 'ExitPlanMode', {})], + }), + { + type: 'approval_result', + record: { + turnId: 0, + toolCallId: 'call_exit_reject', + action: 'Review plan', + toolName: 'ExitPlanMode', + result: { decision: 'rejected', selectedLabel: 'Reject' }, + }, + }, + message('tool', [{ type: 'text', text: 'Plan rejected by user. Plan mode remains active.' }], { + toolCallId: 'call_exit_reject', + isError: true, + }), + message('assistant', [], { + toolCalls: [toolCall('call_exit_final', 'ExitPlanMode', {})], + }), + { + type: 'approval_result', + record: { + turnId: 1, + toolCallId: 'call_exit_final', + action: 'Review plan', + toolName: 'ExitPlanMode', + result: { decision: 'approved', selectedLabel: 'Approve' }, + }, + }, + message( + 'tool', + [ + { + type: 'text', + text: + 'Exited plan mode. Plan mode deactivated. All tools are now available.\n' + + 'Plan saved to: /tmp/plans/final-plan.md\n\n' + + '## Approved Plan:\n# Final Plan\n\n- replay final approved plan', + }, + ], + { toolCallId: 'call_exit_final' }, + ), + { type: 'plan_updated', enabled: false }, + ]); + + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + + expect(transcript).toContain('Plan review rejected'); + expect(transcript).toContain('Final Plan'); + expect(transcript).toContain('replay final approved plan'); + expect(transcript).not.toContain('Plan rejected by user.'); + expect(transcript).not.toContain('Plan mode: OFF'); + }); +}); diff --git a/apps/kimi-code/test/tui/replay-ops.test.ts b/apps/kimi-code/test/tui/replay-ops.test.ts deleted file mode 100644 index a94c3504f..000000000 --- a/apps/kimi-code/test/tui/replay-ops.test.ts +++ /dev/null @@ -1,556 +0,0 @@ -import type { - AgentReplayRecord, - BackgroundTaskInfo, - ContentPart, - PromptOrigin, - Role, - ToolCall, -} from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it } from 'vitest'; - -import { projectReplayRecords } from '#/tui/actions/replay-ops'; - -interface ReplayMessageExtra { - readonly toolCalls?: readonly ToolCall[]; - readonly toolCallId?: string; - readonly origin?: PromptOrigin; - readonly isError?: boolean; -} - -function message( - role: Role, - content: readonly ContentPart[], - extra: ReplayMessageExtra = {}, -): AgentReplayRecord { - return { - type: 'message', - message: { - role, - content: [...content], - toolCalls: [...(extra.toolCalls ?? [])], - toolCallId: extra.toolCallId, - origin: extra.origin, - isError: extra.isError, - }, - }; -} - -function backgroundTask( - taskId: string, - description: string, - status: BackgroundTaskInfo['status'] = 'running', -): BackgroundTaskInfo { - return { - taskId, - command: `[agent] ${description}`, - description, - status, - pid: 0, - exitCode: status === 'completed' ? 0 : null, - startedAt: 1, - endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, - }; -} - -describe('projectReplayRecords', () => { - it('projects only the most recent ten visible user turns from agent replay', () => { - const projected = projectReplayRecords( - Array.from({ length: 12 }, (_, index) => [ - message('user', [{ type: 'text', text: `prompt ${index}` }]), - message('assistant', [{ type: 'text', text: `answer ${index}` }]), - ]).flat(), - ); - - expect( - projected.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.content), - ).toEqual([ - 'prompt 2', - 'prompt 3', - 'prompt 4', - 'prompt 5', - 'prompt 6', - 'prompt 7', - 'prompt 8', - 'prompt 9', - 'prompt 10', - 'prompt 11', - ]); - expect( - projected.entries.filter((entry) => entry.kind === 'assistant').map((entry) => entry.content), - ).toEqual([ - 'answer 2', - 'answer 3', - 'answer 4', - 'answer 5', - 'answer 6', - 'answer 7', - 'answer 8', - 'answer 9', - 'answer 10', - 'answer 11', - ]); - }); - - it('does not count model-triggered skill activations as user turns', () => { - const records: AgentReplayRecord[] = Array.from({ length: 9 }, (_, index) => [ - message('user', [{ type: 'text', text: `prompt ${index}` }]), - message('assistant', [{ type: 'text', text: `answer ${index}` }]), - ]).flat(); - for (const index of [0, 1, 2, 3]) { - records.push( - message('user', [{ type: 'text', text: `Skill body ${index}` }], { - origin: { - kind: 'skill_activation', - activationId: `act-${index}`, - skillName: 'review', - trigger: 'model-tool', - }, - }), - ); - } - - const projected = projectReplayRecords(records); - - expect( - projected.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.content), - ).toEqual([ - 'prompt 0', - 'prompt 1', - 'prompt 2', - 'prompt 3', - 'prompt 4', - 'prompt 5', - 'prompt 6', - 'prompt 7', - 'prompt 8', - ]); - expect(projected.entries.filter((entry) => entry.kind === 'skill_activation')).toHaveLength(4); - }); - - it('projects UserPromptSubmit hook results as assistant transcript entries', () => { - const hookResult = - '\nhook response 1\n\n\nhook response 2\n'; - const projected = projectReplayRecords([ - message('user', [{ type: 'text', text: 'prompt' }]), - message('user', [{ type: 'text', text: hookResult }], { - origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, - }), - message('assistant', [{ type: 'text', text: 'model response' }]), - ]); - - expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ - ['user', 'prompt'], - [ - 'assistant', - '*UserPromptSubmit hook*\n\nhook response 1\n\n*UserPromptSubmit hook*\n\nhook response 2', - ], - ['assistant', 'model response'], - ]); - }); - - it('projects blocking UserPromptSubmit hook results from replayed assistant entries', () => { - const hookResult = - '\nblocked reason\n'; - const projected = projectReplayRecords([ - message('user', [{ type: 'text', text: 'blocked prompt' }]), - message('assistant', [{ type: 'text', text: hookResult }], { - origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true }, - }), - ]); - - expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ - ['user', 'blocked prompt'], - ['assistant', '*UserPromptSubmit hook blocked*\n\nblocked reason'], - ]); - }); - - it('does not infer blocked UserPromptSubmit hook results from assistant role alone', () => { - const hookResult = - '\nlegacy hook response\n'; - const projected = projectReplayRecords([ - message('user', [{ type: 'text', text: 'prompt' }]), - message('assistant', [{ type: 'text', text: hookResult }], { - origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, - }), - ]); - - expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ - ['user', 'prompt'], - ['assistant', '*UserPromptSubmit hook*\n\nlegacy hook response'], - ]); - }); - - it('preserves literal hook result XML from normal assistant replies', () => { - const hookResult = - '\nhook response\n'; - const projected = projectReplayRecords([ - message('user', [{ type: 'text', text: 'show me the hook XML' }]), - message('assistant', [{ type: 'text', text: hookResult }]), - ]); - - expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ - ['user', 'show me the hook XML'], - ['assistant', hookResult], - ]); - }); - - it('projects user messages plus thinking and assistant content', () => { - const projected = projectReplayRecords([ - message('user', [{ type: 'text', text: 'hello' }]), - message('assistant', [ - { type: 'think', think: 'thinking...' }, - { type: 'text', text: 'answer' }, - ]), - ]); - - expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([ - ['user', 'hello'], - ['thinking', 'thinking...'], - ['assistant', 'answer'], - ]); - }); - - it('projects skill activation origin metadata without exposing the full prompt', () => { - const projected = projectReplayRecords([ - message( - 'user', - [{ type: 'text', text: 'Review the requested file.\n\nUser request:\nsrc/app.ts' }], - { - origin: { - kind: 'skill_activation', - activationId: 'act-1', - skillName: 'review', - skillArgs: 'src/app.ts', - trigger: 'user-slash', - }, - }, - ), - ]); - - expect(projected.entries).toHaveLength(1); - expect(projected.entries[0]).toEqual( - expect.objectContaining({ - kind: 'skill_activation', - content: 'Activated skill: review', - skillActivationId: 'act-1', - skillName: 'review', - skillArgs: 'src/app.ts', - }), - ); - expect(JSON.stringify(projected.entries)).not.toContain('Review the requested file'); - }); - - it('deduplicates replayed skill activation cards by activation id', () => { - const record = message('user', [{ type: 'text', text: 'Skill body' }], { - origin: { - kind: 'skill_activation', - activationId: 'act-1', - skillName: 'review', - trigger: 'user-slash', - }, - }); - - const projected = projectReplayRecords([record, record]); - - expect(projected.entries).toHaveLength(1); - expect(projected.entries[0]).toEqual( - expect.objectContaining({ - kind: 'skill_activation', - skillActivationId: 'act-1', - skillName: 'review', - }), - ); - }); - - it('projects background task notifications as status rows', () => { - const notificationXml = [ - '', - 'Title: Background agent completed', - 'Severity: info', - 'Optimize summary completed.', - '', - 'Subagent detailed output should stay out of the transcript row.', - '', - '', - ].join('\n'); - const projected = projectReplayRecords([ - message('assistant', [], { - toolCalls: [ - { - type: 'function', - id: 'call_agent', - name: 'Agent', - arguments: JSON.stringify({ - description: 'Optimize summary', - subagent_type: 'coder', - run_in_background: true, - }), - }, - ], - }), - message('tool', [ - { - type: 'text', - text: [ - 'task_id: agent-bg123', - 'status: running', - 'agent_id: agent-child123', - 'actual_subagent_type: coder', - 'automatic_notification: true', - '', - 'description: Optimize summary', - ].join('\n'), - }, - ], { - toolCallId: 'call_agent', - }), - message('user', [{ type: 'text', text: notificationXml }], { - origin: { - kind: 'background_task', - taskId: 'agent-bg123', - status: 'completed', - notificationId: 'task:agent-bg123:completed', - }, - }), - ], [backgroundTask('agent-bg123', 'Optimize summary', 'completed')]); - - expect(projected.entries.map((entry) => [entry.kind, entry.content])).toEqual([ - ['tool_call', ''], - ['status', 'agent completed in background'], - ]); - expect(projected.entries[1]?.backgroundAgentStatus).toMatchObject({ - phase: 'completed', - headline: 'agent completed in background', - detail: 'Optimize summary', - }); - expect(JSON.stringify(projected.entries)).not.toContain(' { - const projected = projectReplayRecords([ - message('user', [ - { - type: 'text', - text: [ - '', - 'Title: Background agent completed', - 'Severity: info', - 'Optimize ch03 lost.', - '', - ].join('\n'), - }, - ], { - origin: { - kind: 'background_task', - taskId: 'agent-real', - status: 'lost', - notificationId: 'task:agent-real:lost', - }, - }), - ], [backgroundTask('agent-real', 'Real task description', 'lost')]); - - expect(projected.entries).toHaveLength(1); - expect(projected.entries[0]).toEqual( - expect.objectContaining({ - kind: 'status', - content: 'agent lost in background', - backgroundAgentStatus: expect.objectContaining({ - phase: 'failed', - detail: 'Real task description', - }), - }), - ); - }); - - it('renders multimodal user parts as stable placeholders', () => { - const projected = projectReplayRecords([ - message('user', [ - { type: 'text', text: 'look ' }, - { type: 'image_url', imageUrl: { url: 'file:///tmp/a.png' } }, - { type: 'video_url', videoUrl: { url: 'file:///tmp/a.mov' } }, - ]), - ]); - - expect(projected.entries[0]?.content).toBe( - 'look