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
This commit is contained in:
liruifengv 2026-05-27 12:44:30 +08:00 committed by GitHub
parent 49ca317326
commit ce420bf1c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1433 additions and 1737 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Refactor TUI resume replay logic.

View file

@ -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.

View file

@ -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<AppState>) => 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<string>;
/**
* Background agent metadata that remains needed after replay so live
* terminal events can keep rendering transcript copy after resume.
*/
readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>;
}
interface OpenAssistant {
thinking: string[];
text: string[];
}
interface ProjectionState {
entries: TranscriptEntry[];
toolCalls: Map<string, ToolCallBlockData>;
assistant: OpenAssistant;
skillActivationIds: Set<string>;
permissionMode?: PermissionMode;
backgroundAgents: Set<string>;
backgroundAgentMetadata: Map<string, BackgroundAgentMetadata>;
backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>;
}
type BackgroundTaskOrigin = Extract<PromptOrigin, { kind: 'background_task' }>;
const REPLAY_TURN_LIMIT = 10;
export async function hydrateTranscriptFromReplay(
state: TUIState,
hooks: ReplayHydrationHooks,
session: Session,
): Promise<boolean> {
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<string, BackgroundTaskInfo>(
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<string, BackgroundTaskInfo>): {
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<AppState> {
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<string>(),
backgroundAgents: new Set<string>(),
backgroundAgentMetadata: new Map<string, BackgroundAgentMetadata>(),
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 =
/<hook_result\s+hook_event="([^"]+)">\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<string, unknown> {
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<string, unknown> {
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();
}

View file

@ -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)}`;
}
}

View file

@ -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.

View file

@ -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<boolean> {
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<string, BackgroundTaskInfo>(
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<AgentReplayRecord, { type: 'approval_result' }>['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<AgentReplayRecord, { type: 'approval_result' }>['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<PromptOrigin, { kind: 'background_task' }>,
): 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;
}

View file

@ -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;
}

View file

@ -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<string, ToolCallBlockData>;
completedToolCallIds: Set<string>;
skillActivationIds: Set<string>;
suppressNextPlanModeOffNotice: boolean;
}
export interface SkillActivationProjection {
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
}
export interface ReplayBackgroundProjection {
readonly backgroundAgents: ReadonlySet<string>;
readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>;
}
export function appStateFromResumeAgent(agent: ResumedAgentState): Partial<AppState> {
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<string, BackgroundTaskInfo>): {
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<string>();
const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>();
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<PromptOrigin, { kind: 'background_task' }> | 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<string, unknown> {
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 =
/<hook_result\s+hook_event="([^"]+)">\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<string, unknown> {
return typeof value === 'object' && value !== null;
}

View file

@ -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<TUIState['todoPanel']['setTodos']>[0],
): void {
state.todoPanel.setTodos(todos);
state.todoPanelContainer.clear();
if (!state.todoPanel.isEmpty()) {
state.todoPanelContainer.addChild(state.todoPanel);
}
}
function sessionWithToolStore(toolStore: Record<string, unknown>): 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<typeof setTodoList>[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);
});
});

View file

@ -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.');
});

View file

@ -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]!;

View file

@ -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<ApprovalResponse>)
| 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 () => ({

View file

@ -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<boolean>;
switchToSession(session: Session, statusMessage: string): Promise<void>;
}
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<string, unknown>): ToolCall {
return {
type: 'function',
id,
name,
arguments: JSON.stringify(args),
};
}
function baseAgentState(
replay: readonly AgentReplayRecord[],
overrides: Partial<ResumedAgentState> = {},
): 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<ResumedAgentState> = {},
): 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<ReplayDriver> {
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<ResumedAgentState> = {},
): Promise<ReplayDriver> {
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 =
'<hook_result hook_event="UserPromptSubmit">\nhook response 1\n</hook_result>\n' +
'<hook_result hook_event="UserPromptSubmit">\nhook response 2\n</hook_result>';
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');
});
});

View file

@ -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 =
'<hook_result hook_event="UserPromptSubmit">\nhook response 1\n</hook_result>\n<hook_result hook_event="UserPromptSubmit">\nhook response 2\n</hook_result>';
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 =
'<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>';
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 =
'<hook_result hook_event="UserPromptSubmit">\nlegacy hook response\n</hook_result>';
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 =
'<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>';
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 = [
'<notification id="task:agent-bg123:completed" category="task" type="task.completed" source_kind="background_task" source_id="agent-bg123">',
'Title: Background agent completed',
'Severity: info',
'Optimize summary completed.',
'<task-notification>',
'Subagent detailed output should stay out of the transcript row.',
'</task-notification>',
'</notification>',
].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('<notification');
expect(JSON.stringify(projected.entries)).not.toContain('Subagent detailed output');
});
it('uses background notification origin over XML attributes', () => {
const projected = projectReplayRecords([
message('user', [
{
type: 'text',
text: [
'<notification id="task:wrong:completed" category="task" type="task.completed" source_kind="background_task" source_id="wrong">',
'Title: Background agent completed',
'Severity: info',
'Optimize ch03 lost.',
'</notification>',
].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 <image url="file:///tmp/a.png"><video url="file:///tmp/a.mov">',
);
});
it('summarizes data URLs in resumed multimodal user parts', () => {
const projected = projectReplayRecords([
message('user', [
{ type: 'text', text: 'look ' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } },
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,AQIDBA==' } },
]),
]);
expect(projected.entries[0]?.content).toBe('look [image image/png, 2 B][video video/mp4, 4 B]');
expect(projected.entries[0]?.content).not.toContain('qrs=');
expect(projected.entries[0]?.content).not.toContain('AQIDBA==');
});
it('pairs tool results with their tool call entry', () => {
const projected = projectReplayRecords([
message('assistant', [], {
toolCalls: [
{
type: 'function',
id: 'tc_1',
name: 'Bash',
arguments: '{"command":"pwd"}',
},
],
}),
message('tool', [{ type: 'text', text: 'done' }], {
toolCallId: 'tc_1',
}),
]);
expect(projected.entries).toHaveLength(1);
expect(projected.entries[0]?.toolCallData).toMatchObject({
id: 'tc_1',
name: 'Bash',
result: { tool_call_id: 'tc_1', output: 'done' },
});
});
it('preserves failed tool result state', () => {
const projected = projectReplayRecords([
message('assistant', [], {
toolCalls: [
{
type: 'function',
id: 'tc_1',
name: 'Bash',
arguments: '{"command":"false"}',
},
],
}),
message('tool', [{ type: 'text', text: 'failed' }], {
toolCallId: 'tc_1',
isError: true,
}),
]);
expect(projected.entries[0]?.toolCallData?.result).toMatchObject({
tool_call_id: 'tc_1',
output: 'failed',
is_error: true,
});
});
it('projects resumed assistant text, tool call, and tool result records in order', () => {
const projected = projectReplayRecords([
message('user', [{ type: 'text', text: 'try call a tool' }]),
message(
'assistant',
[
{ type: 'think', think: 'I should call Bash.' },
{ type: 'text', text: 'Calling Bash now.' },
],
{
toolCalls: [
{
type: 'function',
id: 'call_resume_bash',
name: 'Bash',
arguments: '{"command":"echo ok"}',
},
],
},
),
message('tool', [{ type: 'text', text: 'ok' }], {
toolCallId: 'call_resume_bash',
}),
]);
expect(projected.entries.map((entry) => [entry.kind, entry.content])).toEqual([
['user', 'try call a tool'],
['thinking', 'I should call Bash.'],
['assistant', 'Calling Bash now.'],
['tool_call', ''],
]);
expect(projected.entries[3]?.toolCallData).toMatchObject({
id: 'call_resume_bash',
name: 'Bash',
args: { command: 'echo ok' },
result: { tool_call_id: 'call_resume_bash', output: 'ok' },
});
});
it('keeps media-bearing tool results as a JSON envelope', () => {
const mediaContent: ContentPart[] = [
{ type: 'text', text: '<image path="/tmp/a.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,QUJD' } },
{ type: 'text', text: '</image>' },
];
const projected = projectReplayRecords([
message('assistant', [], {
toolCalls: [
{
type: 'function',
id: 'tc_media',
name: 'ReadMediaFile',
arguments: '{"path":"/tmp/a.png"}',
},
],
}),
message('tool', mediaContent, {
toolCallId: 'tc_media',
}),
]);
const output = projected.entries[0]?.toolCallData?.result?.output ?? '';
expect(JSON.parse(output)).toEqual(mediaContent);
});
it('projects plan, permission, and approval replay records as notices', () => {
const projected = projectReplayRecords([
{ 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 },
]);
expect(projected.entries.map((e) => [e.kind, e.renderMode, e.content])).toEqual([
['status', 'notice', 'Plan mode: ON'],
['status', 'notice', 'Permission mode: auto'],
['status', 'notice', 'YOLO mode: ON'],
['status', 'notice', 'YOLO mode: OFF'],
['status', 'notice', 'Approved for session: run command'],
['status', 'notice', 'Plan mode: OFF'],
]);
expect(projected.entries[2]?.detail).toBe(
'All actions will be approved automatically. Use with caution.',
);
});
it('ignores config replay records and system injections', () => {
const projected = projectReplayRecords([
{ type: 'config_updated', config: { thinkingLevel: 'off' } },
message('user', [{ type: 'text', text: 'ignore by origin' }], {
origin: { kind: 'injection', variant: 'plan_mode' },
}),
message('user', [{ type: 'text', text: 'visible' }]),
]);
expect(projected.entries.map((e) => [e.kind, e.content])).toEqual([['user', 'visible']]);
});
});