mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
49ca317326
commit
ce420bf1c6
14 changed files with 1433 additions and 1737 deletions
5
.changeset/replay-live-tool-groups.md
Normal file
5
.changeset/replay-live-tool-groups.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Refactor TUI resume replay logic.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
apps/kimi-code/src/tui/utils/hook-result-format.ts
Normal file
18
apps/kimi-code/src/tui/utils/hook-result-format.ts
Normal 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;
|
||||
}
|
||||
294
apps/kimi-code/src/tui/utils/message-replay.ts
Normal file
294
apps/kimi-code/src/tui/utils/message-replay.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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]!;
|
||||
|
|
|
|||
|
|
@ -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 () => ({
|
||||
|
|
|
|||
501
apps/kimi-code/test/tui/message-replay.test.ts
Normal file
501
apps/kimi-code/test/tui/message-replay.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue