diff --git a/.changeset/long-session-tui-performance.md b/.changeset/long-session-tui-performance.md new file mode 100644 index 0000000000..881873520b --- /dev/null +++ b/.changeset/long-session-tui-performance.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/agent-core": patch +--- + +Improve TUI performance and resume speed for long-running sessions. diff --git a/.changeset/pi-tui-frame-line-reuse.md b/.changeset/pi-tui-frame-line-reuse.md new file mode 100644 index 0000000000..b75646a037 --- /dev/null +++ b/.changeset/pi-tui-frame-line-reuse.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Speed up the terminal renderer in long sessions by reusing processed lines across frames. diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index bd427277d4..5db5871744 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -107,8 +107,9 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise 0) parts.push(`thinking ${this.thinking} times`); if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (this.message > 0) parts.push(`${this.message} messages`); if (parts.length === 0) return []; return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index cec7fbd133..e7241e963a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -96,3 +96,17 @@ export class UserMessageComponent implements Component { function isImageLine(line: string): boolean { return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); } + +/** + * Invisible turn-boundary marker for replay. Some replayed records start a + * new turn without anything to show — the goal driver's synthetic + * continuation prompt is model-facing and never rendered live — but the + * transcript still needs a mounted boundary component so step/assistant + * folding (and window trimming) can find the turn edges. Renders zero lines. + */ +export class ReplayTurnBoundaryComponent implements Component { + invalidate(): void {} + render(_width: number): string[] { + return []; + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 00d79c3d8c..3a8b5ada3d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -936,8 +936,9 @@ export class SessionEventHandler { const children = state.transcriptContainer.children; const idx = children.indexOf(spinner); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = status; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(status); } diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a20e8a513a..f1a027a021 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { ReplayTurnBoundaryComponent } from '../components/messages/user-message'; import { currentTheme } from '../theme'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { @@ -23,6 +24,7 @@ import { formatBackgroundAgentTranscript } from '../utils/background-agent-statu import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { formatBashOutputForDisplay } from '../utils/shell-output'; +import { markTranscriptComponent } from '../utils/transcript-component-metadata'; import { appStateFromResumeAgent, backgroundOrigin, @@ -305,6 +307,16 @@ export class SessionReplayRenderer { // reminders, stop-hook reasons, …) are model-facing only: the live event // stream never renders them, so replay must not leak them either. if (message.origin?.kind === 'system_trigger') { + if (message.origin.name === 'goal_continuation') { + // The goal driver's synthetic "continue" prompt starts a new replay + // turn even though nothing visible is mounted: advance the turn and + // mark an invisible boundary so each goal round groups under its own + // turn and step/assistant folding can find the turn edges. + this.advanceTurn(context); + const boundary = new ReplayTurnBoundaryComponent(); + markTranscriptComponent(boundary, replayEntry(context, 'user', '', 'plain')); + this.host.state.transcriptContainer.addChild(boundary); + } return; } @@ -647,8 +659,9 @@ export class SessionReplayRenderer { (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, ); if (childIndex >= 0) { + // Structural removal only: the container's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(childIndex, 1); - state.transcriptContainer.invalidate(); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 484b9f02ad..5b6a35d7f5 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -36,6 +36,7 @@ export interface StreamingUIHost { shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; mergeCurrentTurnSteps(): void; + mergeCompletedTurnAssistants(): void; } export class StreamingUIController { @@ -555,6 +556,9 @@ export class StreamingUIController { const completedTurnKey = this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); + // The finished turn keeps only its conclusion-bearing tail; intermediate + // chatter folds into the step summary. + this.host.mergeCompletedTurnAssistants(); this.resetToolCallState(); this._currentTurnId = undefined; @@ -835,8 +839,9 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = group; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(group); } @@ -892,8 +897,9 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). children[idx] = group; - state.transcriptContainer.invalidate(); } else { state.transcriptContainer.addChild(group); } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index deb407bfc2..f2281ea54c 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -552,8 +552,9 @@ export class SubAgentEventHandler { const children = this.host.state.transcriptContainer.children; const index = children.indexOf(progress); if (index >= 0) { + // Structural removal only: GutterContainer's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(index, 1); - this.host.state.transcriptContainer.invalidate(); } this.host.updateActivityPane(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9d03f9395b..464c7237aa 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -85,7 +85,10 @@ import { import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import { UserMessageComponent } from './components/messages/user-message'; +import { + ReplayTurnBoundaryComponent, + UserMessageComponent, +} from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; @@ -134,6 +137,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -150,6 +154,8 @@ import { nextTranscriptId } from './utils/transcript-id'; import { TRANSCRIPT_EXPAND_TURNS, TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, TRANSCRIPT_KEEP_RECENT_STEPS, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_WINDOW_ENABLED, @@ -774,6 +780,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: startup.sessionFlag, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -783,6 +790,7 @@ export class KimiTUI { session = await this.harness.resumeSession({ id: target.id, additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -1700,7 +1708,10 @@ export class KimiTUI { let session: Session; try { - session = await this.harness.resumeSession({ id: targetSessionId }); + session = await this.harness.resumeSession({ + id: targetSessionId, + replayTurnLimit: REPLAY_TURN_LIMIT, + }); } catch (error) { const msg = formatErrorMessage(error); this.showError(`Failed to resume session ${targetSessionId}: ${msg}`); @@ -2012,7 +2023,8 @@ export class KimiTUI { if ( !(child instanceof UserMessageComponent) && !(child instanceof SkillActivationComponent) && - !(child instanceof PluginCommandComponent) + !(child instanceof PluginCommandComponent) && + !(child instanceof ReplayTurnBoundaryComponent) ) { return false; } @@ -2100,7 +2112,28 @@ export class KimiTUI { } mergeCurrentTurnSteps(): boolean { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return false; + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + ); + } + + /** + * Fold the just-finished turn's assistant messages down to the completed-turn + * cap: while a turn is live it may keep TRANSCRIPT_KEEP_RECENT_ASSISTANT + * messages mounted, but once it ends only the conclusion-bearing tail stays. + * Called when a turn finishes; the finished turn is still the current one at + * that point (no newer boundary exists yet). + */ + mergeCompletedTurnAssistants(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + ); + } + + private foldCurrentTurnContent(keepSteps: number, keepAssistants: number): boolean { + if (keepSteps <= 0 && keepAssistants <= 0) return false; const children = this.state.transcriptContainer.children; // Find the start of the current turn (last turn-starting user message). @@ -2113,22 +2146,34 @@ export class KimiTUI { } if (turnStart < 0) return false; - // Locate an existing summary, the assistant message, and the mergeable steps. + // Locate an existing summary, the assistant messages, and the mergeable steps. let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: number[] = []; for (let i = turnStart + 1; i < children.length; i++) { const child = children[i]!; if (child instanceof StepSummaryComponent) { summaryIndex = i; continue; } - if (child instanceof AssistantMessageComponent) continue; + if (child instanceof AssistantMessageComponent) { + assistantIndices.push(i); + continue; + } stepIndices.push(i); } - if (stepIndices.length <= TRANSCRIPT_KEEP_RECENT_STEPS) return false; - const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; - const toMergeIndices = stepIndices.slice(0, mergeCount); + // Fold the oldest steps / assistant messages beyond their respective caps; + // the most recent ones stay mounted. Children are chronological, so the + // oldest of each kind sit at the front of their index lists. + const stepMergeCount = keepSteps > 0 ? Math.max(0, stepIndices.length - keepSteps) : 0; + const assistantMergeCount = + keepAssistants > 0 ? Math.max(0, assistantIndices.length - keepAssistants) : 0; + if (stepMergeCount === 0 && assistantMergeCount === 0) return false; + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; @@ -2137,15 +2182,15 @@ export class KimiTUI { if (child instanceof ThinkingComponent) thinkingCount++; else if (child instanceof ToolCallComponent) toolCount++; } - if (thinkingCount === 0 && toolCount === 0) return false; + if (thinkingCount === 0 && toolCount === 0 && assistantMergeCount === 0) return false; let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } // Rebuild children: keep everything except the merged steps, with the summary @@ -2170,7 +2215,8 @@ export class KimiTUI { } mergeAllTurnSteps(): void { - if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0) return; + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) + return; const children = this.state.transcriptContainer.children; const boundaries: number[] = []; @@ -2190,16 +2236,29 @@ export class KimiTUI { let summaryIndex = -1; const stepIndices: number[] = []; + const assistantIndices: number[] = []; for (let i = turnStart + 1; i < turnEnd; i++) { const child = children[i]!; if (child instanceof StepSummaryComponent) summaryIndex = i; - else if (child instanceof AssistantMessageComponent) continue; + else if (child instanceof AssistantMessageComponent) assistantIndices.push(i); else stepIndices.push(i); } - if (stepIndices.length > TRANSCRIPT_KEEP_RECENT_STEPS) { - const mergeCount = stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS; - const toMergeIndices = stepIndices.slice(0, mergeCount); + const stepMergeCount = + TRANSCRIPT_KEEP_RECENT_STEPS > 0 + ? Math.max(0, stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS) + : 0; + // Replayed turns are all completed turns, so the stricter completed-turn + // assistant cap applies (matching what live turns fold to on turn end). + const assistantMergeCount = + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED > 0 + ? Math.max(0, assistantIndices.length - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED) + : 0; + if (stepMergeCount > 0 || assistantMergeCount > 0) { + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; let thinkingCount = 0; let toolCount = 0; for (const idx of toMergeIndices) { @@ -2210,10 +2269,10 @@ export class KimiTUI { let summary: StepSummaryComponent; if (summaryIndex >= 0) { summary = children[summaryIndex] as StepSummaryComponent; - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } else { summary = new StepSummaryComponent(); - summary.addCounts(thinkingCount, toolCount); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); } newChildren.push(summary); for (const idx of toMergeIndices) toDispose.push(children[idx]!); diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 99441472b9..d778b9a472 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -7,6 +7,7 @@ import type { ResumedAgentState, ToolCall, } from '@moonshot-ai/kimi-code-sdk'; +import { limitAgentReplayByTurns } from '@moonshot-ai/kimi-code-sdk'; import type { AppState, @@ -132,12 +133,10 @@ 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]); + // Defensive slice — the core already trims the replay when the caller passes + // `replayTurnLimit` on resume; the boundary predicate lives in agent-core + // (`limitAgentReplayByTurns`) and is re-exported through the SDK. + return limitAgentReplayByTurns(records, maxTurns); } export function replayEntry( @@ -264,33 +263,6 @@ export function formatHookResultMessageForTranscript( 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 'plugin_command': - return message.origin.trigger === 'user-slash'; - case 'shell_command': - // A `!` command's input is a user-turn anchor; its output is not. - return message.origin.phase === 'input'; - case 'background_task': - case 'compaction_summary': - case 'cron_job': - case 'cron_missed': - case 'hook_result': - case 'injection': - case 'retry': - case 'system_trigger': - return false; - } -} - function parseReplayToolArguments(value: string | null): Record { if (value === null || value.length === 0) return {}; try { diff --git a/apps/kimi-code/src/tui/utils/transcript-window.ts b/apps/kimi-code/src/tui/utils/transcript-window.ts index ed0083b6fc..7f53fe6568 100644 --- a/apps/kimi-code/src/tui/utils/transcript-window.ts +++ b/apps/kimi-code/src/tui/utils/transcript-window.ts @@ -5,6 +5,9 @@ * responsive and bounded, we only keep the most recent N *turns* (a turn = a * user prompt plus everything the assistant does in response, identified by a * shared `turnId`), and destroy older turns wholesale (component + entry). + * Within a kept turn, older steps — and assistant messages beyond a cap — are + * folded into a collapsed summary line so a single long turn cannot grow the + * mounted component tree without bound. * * All threshold logic here is pure so it can be unit-tested in isolation; the * constants are the production defaults passed in by the TUI. @@ -40,6 +43,19 @@ export const TRANSCRIPT_HYSTERESIS = readEnvInt('KIMI_CODE_TUI_HYSTERESIS', 5); /** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_STEPS', 30); +/** Keep this many recent assistant messages mounted inside the active turn; older ones fold into the step summary. `0` disables folding. */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT = readEnvInt('KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT', 20); + +/** + * Once a turn ends, fold all but its last few assistant messages into the + * step summary — intermediate chatter is rarely re-read, while the tail + * usually holds the conclusion. `0` disables folding. + */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED = readEnvInt( + 'KIMI_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED', + 2, +); + export interface TranscriptTurn { readonly turnId: string | undefined; readonly entries: TranscriptEntry[]; diff --git a/apps/kimi-code/test/tui/components/messages/step-summary.test.ts b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts new file mode 100644 index 0000000000..85626c8ae6 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/step-summary.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('StepSummaryComponent', () => { + it('renders nothing when empty', () => { + const component = new StepSummaryComponent(); + expect(component.isEmpty).toBe(true); + expect(component.render(80)).toEqual([]); + }); + + it('renders thinking and tool counts without a message part', () => { + const component = new StepSummaryComponent(); + component.addCounts(5, 50); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('thinking 5 times'); + expect(out).toContain('call 50 tools'); + expect(out).not.toContain('messages'); + }); + + it('renders folded assistant message counts and accumulates', () => { + const component = new StepSummaryComponent(); + component.addCounts(0, 0, 3); + component.addCounts(2, 4, 5); + const out = strip(component.render(80).join('\n')); + expect(component.isEmpty).toBe(false); + expect(out).toContain('thinking 2 times'); + expect(out).toContain('call 4 tools'); + expect(out).toContain('8 messages'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 9e9804e262..9fe501d9e0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -19,6 +19,14 @@ import { AgentSwarmProgressComponent, agentSwarmGridHeightForTerminalRows, } from '#/tui/components/messages/agent-swarm-progress'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; @@ -5483,3 +5491,123 @@ describe('/effort support_efforts override', () => { expect(session.setThinking).not.toHaveBeenCalled(); }); }); + +describe('transcript step and assistant folding', () => { + function driveSteps(driver: MessageDriver, cycles: number): void { + for (let i = 0; i < cycles; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: `msg-${i} `, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + name: 'Bash', + args: { command: 'ls' }, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + output: 'ok', + isError: undefined, + } as Event, + vi.fn(), + ); + } + } + + it('folds the oldest assistant messages and steps beyond their per-turn caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('fold me'); + + const cycles = Math.max(TRANSCRIPT_KEEP_RECENT_ASSISTANT, TRANSCRIPT_KEEP_RECENT_STEPS) + 7; + driveSteps(driver, cycles); + + const children = driver.state.transcriptContainer.children; + const assistantCount = children.filter( + (child) => child instanceof AssistantMessageComponent, + ).length; + const toolCount = children.filter((child) => child instanceof ToolCallComponent).length; + expect(assistantCount).toBe(TRANSCRIPT_KEEP_RECENT_ASSISTANT); + expect(toolCount).toBe(TRANSCRIPT_KEEP_RECENT_STEPS); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${cycles - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT} messages`); + + // Folding drops mounted components only; every transcript entry is kept. + const assistantEntries = driver.state.transcriptEntries.filter( + (entry) => entry.kind === 'assistant', + ); + expect(assistantEntries).toHaveLength(cycles); + }); + + it('does not fold a turn within the caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('small turn'); + driveSteps(driver, 3); + + const children = driver.state.transcriptContainer.children; + expect(children.filter((child) => child instanceof AssistantMessageComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof StepSummaryComponent)).toHaveLength(0); + }); + + it('folds a completed turn down to its conclusion tail on turn end', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('round one'); + const cycles = 10; + driveSteps(driver, cycles); + + // Below the active-turn caps, nothing folds while the turn is live. + let children = driver.state.transcriptContainer.children; + expect( + children.filter((child) => child instanceof AssistantMessageComponent), + ).toHaveLength(cycles); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + + children = driver.state.transcriptContainer.children; + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); + + // Steps below the step cap are untouched by the completed-turn fold. + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(cycles); + + // The conclusion stays mounted. + const lastAssistant = assistants.at(-1)!; + expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index a914ff5234..79f8cfb552 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -13,6 +13,7 @@ import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/co import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { quoteShellArg } from '#/utils/shell-quote'; import { @@ -288,7 +289,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(true); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-latest', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe('ses-latest'); @@ -1476,7 +1480,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-latest', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe(''); @@ -1493,7 +1500,10 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-target', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe(''); }); @@ -1747,7 +1757,10 @@ describe('KimiTUI startup', () => { sessionId: 'ses-target', workDir: String.raw`C:\Users\kimi\project`, }); - expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target' }); + expect(harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses-target', + replayTurnLimit: REPLAY_TURN_LIMIT, + }); expect(driver.state.appState.sessionId).toBe('ses-target'); }); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index d13b8595b2..20d825ce04 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -17,6 +17,13 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -1220,4 +1227,87 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Plan rejected by user.'); expect(transcript).not.toContain('Plan mode: OFF'); }); + + it('trims goal sessions to the most recent goal turns and hides continuation prompts', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + for (let i = 0; i < 25; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + // Continuation prompts are model-facing and never render as user bubbles. + expect(transcript).not.toContain('Continue working toward the active goal.'); + // Only the most recent REPLAY_TURN_LIMIT goal turns are replayed. + expect(transcript).not.toContain('round 0 summary'); + expect(transcript).not.toContain('round 14 summary'); + expect(transcript).toContain('round 15 summary'); + expect(transcript).toContain('round 24 summary'); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ToolCallComponent, + ), + ).toHaveLength(10); + }); + + it('folds oversized goal rounds even though continuation boundaries are hidden', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + // Ten continuation rounds — exactly at the replay turn limit, so nothing + // is trimmed and only folding can bound the oversized final round. + for (let i = 0; i < 9; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + // Final round: 40 tool calls and 5 assistant texts in one continuation turn. + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + for (let t = 0; t < 40; t++) { + replay.push( + message('assistant', t < 5 ? [{ type: 'text', text: `final text ${t}` }] : [], { + toolCalls: [toolCall(`final_${t}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `final_${t}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const children = driver.state.transcriptContainer.children; + + // The oversized round folds to the per-turn caps even with no visible + // boundary component mounted for the continuation prompt. + const tools = children.filter((child) => child instanceof ToolCallComponent); + expect(tools).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_STEPS); + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripAnsi(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${40 - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${5 - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); + + // The folded content is gone from view; the latest work stays. + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('final text 0'); + expect(transcript).toContain('final text 4'); + }); }); diff --git a/apps/kimi-code/test/tui/tui-frame.bench.ts b/apps/kimi-code/test/tui/tui-frame.bench.ts new file mode 100644 index 0000000000..2fc06ec027 --- /dev/null +++ b/apps/kimi-code/test/tui/tui-frame.bench.ts @@ -0,0 +1,101 @@ +/** + * Benchmark for the TUI steady-state frame (Phase: doRender fast path). + * + * Measures the cost of one frame over a very long transcript when only a + * single line changed — the shape every spinner tick and streaming flush + * produces. Component render caches return the same string references for + * unchanged content, so doRender's processed-line reuse turns the frame into + * O(total lines) pointer comparisons plus O(changed lines) real work. A + * regression here re-introduces the per-frame full-transcript processing that + * pegged CPU in long sessions. + * + * Run: + * pnpm --filter @moonshot-ai/kimi-code exec vitest bench test/tui/tui-frame.bench.ts + */ + +import type { Component, Terminal } from '@moonshot-ai/pi-tui'; +import { TUI } from '@moonshot-ai/pi-tui'; +import { bench, describe } from 'vitest'; + +const WIDTH = 120; +const HEIGHT = 40; +const TRANSCRIPT_LINES = 30_000; + +/** Terminal stub that discards output — we benchmark frame computation, not xterm parsing. */ +class StubTerminal implements Terminal { + /** + * Counts writes so the frame's output is an observable side effect; an + * empty write would let the JIT eliminate the whole frame as dead code. + */ + writes = 0; + start(): void {} + stop(): void {} + async drainInput(): Promise {} + write(): void { + this.writes++; + } + get columns(): number { + return WIDTH; + } + get rows(): number { + return HEIGHT; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(): void {} + setProgress(): void {} +} + +/** Returns the same array reference every frame, mirroring the app's cached message components. */ +class StaticTranscript implements Component { + constructor(private readonly lines: string[]) {} + render(): string[] { + return this.lines; + } + invalidate(): void {} +} + +class SpinnerComponent implements Component { + frame = 0; + render(): string[] { + return [`⠋ working (frame ${this.frame})`]; + } + invalidate(): void {} +} + +describe('TUI steady-state frame', () => { + const terminal = new StubTerminal(); + const tui = new TUI(terminal); + const spinner = new SpinnerComponent(); + tui.addChild( + new StaticTranscript( + Array.from( + { length: TRANSCRIPT_LINES }, + (_, i) => `transcript line ${i} — the quick brown fox jumps over the lazy dog`, + ), + ), + ); + tui.addChild(spinner); + tui.start(); + + // doRender is private; the bench drives it directly so the measurement is + // one frame's computation without the 16ms render throttle in between. + const renderFrame = (): void => { + spinner.frame++; + (tui as unknown as { doRender(): void }).doRender(); + }; + renderFrame(); + + // No teardown/stop here: bench-option hooks fire per measured iteration, + // and stopping the TUI would turn every subsequent frame into a no-op. + bench(`${TRANSCRIPT_LINES}-line transcript, one spinner line change per frame`, () => { + renderFrame(); + }); +}); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index aaa2a67a9a..82b170a2c8 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -471,12 +471,13 @@ function buildCronTurn(msg: AppMessage, no: number, kind: 'cron_job' | 'cron_mis /** - * Whether a USER-role message should be shown. Mirrors the TUI's - * isReplayUserTurnRecord: only real user input (origin `user`/absent, or a - * user-typed slash command) is displayed; system-injected user turns - * (compaction summaries, injections, hook results, retries, system triggers, - * background tasks, cron) are hidden. The origin arrives via message metadata - * (see toProtocolMessage in @moonshot-ai/agent-core). + * Whether a USER-role message should be shown. Mirrors agent-core's + * isAgentReplayUserTurnRecord (agent/replay/turns.ts): only real user input + * (origin `user`/absent, or a user-typed slash command) is displayed; + * system-injected user turns (compaction summaries, injections, hook results, + * retries, system triggers, background tasks, cron) are hidden. The origin + * arrives via message metadata (see toProtocolMessage in + * @moonshot-ai/agent-core). */ function isDisplayableUserMessage(msg: AppMessage): boolean { const origin = msg.metadata?.['origin'] as { kind?: string; trigger?: string } | undefined; diff --git a/packages/agent-core/src/agent/replay/turns.ts b/packages/agent-core/src/agent/replay/turns.ts new file mode 100644 index 0000000000..fef7f941ee --- /dev/null +++ b/packages/agent-core/src/agent/replay/turns.ts @@ -0,0 +1,67 @@ +import type { AgentReplayRecord } from '../../rpc/resumed'; + +/** + * User-turn boundary detection over an agent's replay records. + * + * A record starts a new user turn when it is a user-role message that came + * from an actual user action — a typed prompt, a user-invoked skill/plugin + * slash command, or a `!` shell command's input line. System-originated user + * messages (compaction summaries, cron fires, hook results, retries, goal + * reminders, background-task results, injections) continue the current turn + * instead — with one exception: `goal_continuation` prompts. The goal driver + * fires one synthetic continuation prompt per goal turn (see + * agent/turn/index.ts), and the goal system itself counts those as turns, so + * replay trimming treats them as turn boundaries; otherwise a 100-round goal + * would count as a single user turn and resume would replay the entire run. + * + * Source of truth for turn-boundary detection; the TUI mirrors this through + * the SDK re-export instead of keeping its own predicate. + */ +export function isAgentReplayUserTurnRecord(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 'plugin_command': + return message.origin.trigger === 'user-slash'; + case 'shell_command': + // A `!` command's input is a user-turn anchor; its output is not. + return message.origin.phase === 'input'; + case 'background_task': + case 'compaction_summary': + case 'cron_job': + case 'cron_missed': + case 'hook_result': + case 'injection': + case 'retry': + return false; + case 'system_trigger': + // The goal driver fires one synthetic continuation prompt per goal turn + // (agent/turn/index.ts GOAL_CONTINUATION_ORIGIN) — real rounds of work + // the goal system itself counts as turns. All other system triggers are + // reminders that continue the current turn. + return message.origin.name === 'goal_continuation'; + } +} + +/** + * Keep only the most recent `maxTurns` user turns of a replay. `undefined` + * keeps the full replay; `0` or negative returns an empty replay. + */ +export function limitAgentReplayByTurns( + records: readonly AgentReplayRecord[], + maxTurns?: number, +): readonly AgentReplayRecord[] { + if (maxTurns === undefined) return records; + if (maxTurns <= 0) return []; + const turnStarts = records.flatMap((record, index) => + isAgentReplayUserTurnRecord(record) ? [index] : [], + ); + if (turnStarts.length <= maxTurns) return records; + return records.slice(turnStarts[turnStarts.length - maxTurns]); +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 25236a7d2b..46b97172a5 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -8,6 +8,7 @@ export * from './telemetry'; export * from './errors'; export * from './plugin'; export { buildReplay } from './agent/replay/build'; +export { isAgentReplayUserTurnRecord, limitAgentReplayByTurns } from './agent/replay/turns'; export { flushDiagnosticLogs, flushDiagnosticLogsSync, diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 5ae3f283b2..faa44f538f 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -83,6 +83,12 @@ export interface ResumeSessionPayload { readonly additionalDirs?: readonly string[]; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; + /** + * Limit each returned agent replay to the most recent N user turns. Omit to + * return the full replay. Lets UI callers that only render the tail avoid + * serializing the entire history over the RPC boundary. + */ + readonly replayTurnLimit?: number; } export interface ReloadSessionPayload { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 09cf2965ca..4b34590d38 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -12,6 +12,7 @@ import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; import { resolveThinkingEffort } from '../agent/config/thinking'; import { Agent } from '../agent'; +import { limitAgentReplayByTurns } from '../agent/replay/turns'; import { applyPrintModeConfigDefaults, ensureKimiHome, @@ -483,7 +484,13 @@ export class KimiCore implements PromisableMethods { } await active.setBaseAdditionalDirs(additionalDirs); return withAdditionalDirs( - await resumeSessionResult(summary, active, undefined, input.includeSubagents), + await resumeSessionResult( + summary, + active, + undefined, + input.includeSubagents, + input.replayTurnLimit, + ), active, ); } @@ -544,7 +551,13 @@ export class KimiCore implements PromisableMethods { // (and any SDK caller's resumeState) reflects the refreshed plugin context. await session.appendPluginSessionStartReminder(); } - return resumeSessionResult(summary, session, warning, input.includeSubagents); + return resumeSessionResult( + summary, + session, + warning, + input.includeSubagents, + input.replayTurnLimit, + ); } async reloadSession(input: ReloadSessionPayload): Promise { @@ -1482,6 +1495,7 @@ async function resumeSessionResult( session: Session, warning?: string, includeSubagents = false, + replayTurnLimit?: number, ): Promise { if (includeSubagents) { const persistedAgentIds = Object.keys(session.metadata.agents).filter( @@ -1513,7 +1527,7 @@ async function resumeSessionResult( type: agent.type, config, context, - replay: agent.replayBuilder.buildResult(), + replay: limitAgentReplayByTurns(agent.replayBuilder.buildResult(), replayTurnLimit), permission, plan, swarmMode, diff --git a/packages/agent-core/src/services/message/message.ts b/packages/agent-core/src/services/message/message.ts index 3b6ffb197c..1d25ec626f 100644 --- a/packages/agent-core/src/services/message/message.ts +++ b/packages/agent-core/src/services/message/message.ts @@ -269,8 +269,9 @@ export function toProtocolMessage( // Expose the message origin (kosong/agent-core `origin`) via metadata so REST // clients (e.g. the web UI) can hide injected/system user turns — compaction // summaries, injections, hook results, retries, system triggers, cron, etc. — - // the same way the TUI does (see isReplayUserTurnRecord). Absent for plain - // user/assistant/tool messages with no origin. + // the same way the TUI does (see isAgentReplayUserTurnRecord in + // agent/replay/turns.ts). Absent for plain user/assistant/tool messages with + // no origin. const metadata = msg.origin !== undefined ? { origin: msg.origin } : undefined; return { id, diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 24b14a8ae4..0bb76debe9 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -5,10 +5,13 @@ import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; import type { AgentRecord } from '../../src/agent'; +import type { PromptOrigin } from '../../src/agent/context'; import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, } from '../../src/agent/records'; +import { limitAgentReplayByTurns } from '../../src/agent/replay/turns'; +import type { AgentReplayRecord } from '../../src/rpc/resumed'; import { BackgroundTaskPersistence } from '../../src/agent/background'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; @@ -1688,3 +1691,77 @@ function findRpcEvent( ) { return ctxEvents.find((entry) => entry.type === '[rpc]' && entry.event === event); } + +describe('limitAgentReplayByTurns', () => { + const replayMessage = ( + role: 'user' | 'assistant', + text: string, + origin?: PromptOrigin, + ): AgentReplayRecord => + ({ + time: 0, + type: 'message', + message: { role, content: [{ type: 'text', text }], ...(origin ? { origin } : {}) }, + }) as AgentReplayRecord; + + it('returns the full replay when maxTurns is undefined', () => { + const records = [replayMessage('user', 'a'), replayMessage('assistant', 'b')]; + expect(limitAgentReplayByTurns(records, undefined)).toBe(records); + }); + + it('returns an empty replay when maxTurns is zero', () => { + expect(limitAgentReplayByTurns([replayMessage('user', 'a')], 0)).toEqual([]); + }); + + it('keeps the most recent user turns, treating system-triggered user messages as continuations', () => { + const records = [ + replayMessage('user', 'first', { kind: 'user' }), + replayMessage('assistant', 'one'), + replayMessage('user', 'second', { kind: 'user' }), + replayMessage('user', 'goal continuation', { kind: 'system_trigger', name: 'goal' }), + replayMessage('assistant', 'two'), + replayMessage('user', 'third', { kind: 'user' }), + replayMessage('assistant', 'three'), + ]; + expect(limitAgentReplayByTurns(records, 2)).toEqual(records.slice(2)); + }); + + it('treats user-slash activations and shell command inputs as boundaries, but not their outputs', () => { + const records = [ + replayMessage('user', 't1', { kind: 'user' }), + replayMessage('user', '/skill', { + kind: 'skill_activation', + activationId: 'act-1', + skillName: 'demo', + trigger: 'user-slash', + }), + replayMessage('user', '!ls', { kind: 'shell_command', phase: 'input' }), + replayMessage('user', 'ls output', { kind: 'shell_command', phase: 'output' }), + replayMessage('user', 't2', { kind: 'user' }), + ]; + expect(limitAgentReplayByTurns(records, 2)).toEqual(records.slice(2)); + }); + + it('treats goal continuation prompts as turn boundaries, but not other system triggers', () => { + const rounds = (n: number): AgentReplayRecord[] => + Array.from({ length: n }, (_, i) => [ + replayMessage('user', 'Resume the active goal.', { + kind: 'system_trigger', + name: 'goal_continuation', + }), + replayMessage('assistant', `round ${i}`), + ]).flat(); + const records = [ + replayMessage('user', '/goal ship it', { kind: 'user' }), + ...rounds(15), + replayMessage('user', 'cancelled reminder', { + kind: 'system_trigger', + name: 'goal_cancelled', + }), + ]; + const limited = limitAgentReplayByTurns(records, 10); + // The /goal prompt and the first five continuation rounds fall away; the + // trailing reminder stays attached to the last kept turn. + expect(limited).toEqual(records.slice(11)); + }); +}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 6b731391ee..84992c2631 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -64,6 +64,7 @@ export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agen // by hosts (e.g. the CLI's server telemetry bootstrap) that need to inspect // config without spinning up a full KimiCore. export { effectiveModelAlias, loadRuntimeConfigSafe, resolveConfigPath } from '@moonshot-ai/agent-core'; +export { limitAgentReplayByTurns } from '@moonshot-ai/agent-core'; // Process-wide HTTP proxy bootstrap — installed once at CLI startup so all // outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c72419e3f4..0968d96a18 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -130,6 +130,12 @@ export interface ResumeSessionInput { readonly additionalDirs?: readonly string[]; /** Include persisted subagent states in the returned replay snapshot. */ readonly includeSubagents?: boolean; + /** + * Limit each returned agent replay to the most recent N user turns. Omit to + * return the full replay. Lets UI callers that only render the tail avoid + * transferring the entire history over the RPC boundary. + */ + readonly replayTurnLimit?: number; readonly sessionStartedProperties?: TelemetryProperties; } diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 500e9627b7..c25bd9efe3 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -8,8 +8,9 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 1. **`src/components/editor.ts` — `wordWrapLine` single-grapheme recursion guard**: when a segment cannot be split further (a single grapheme) and is wider than `maxWidth`, do not recurse (upstream recurses infinitely and overflows the stack at maxWidth=1 with CJK). The guard must be based on grapheme count (`graphemeSegmenter.segment(...)`), not code-unit length — `grapheme.length` misjudges ZWJ emoji. Guarding tests: "wordWrapLine narrow width" and "Editor narrow width rendering" in `test/editor.test.ts`. 2. **`src/tui.ts` — `Container.render` width clamp**: `width = Math.max(1, width)` at the entry point. Guarding test: "Container width clamping" in `test/tui-render.test.ts`. -3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` before `applyLineResets`; the upstream "write crash log + throw" block in the differential render path has been removed — do not bring it back when syncing. Performance constraint: the truncation check scans every line every frame, so it must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Known boundary: with more than 4096 distinct non-ASCII lines the width cache FIFO thrashes (~30ms/frame); the real fix is a prepared-frame per-row cache, tracked as follow-up work. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`. +3. **`src/tui.ts` — truncate overwide lines instead of throwing**: `doRender` truncates overwide lines with `sliceByColumn` while building the per-line processed output (upstream had a "write crash log + throw" block in the differential render path — do not bring it back when syncing). Performance constraint: the width check must go through the `asciiVisibleWidth` fast path in `utils.ts` first (ANSI-aware ASCII scan with an early exit past the limit) and only fall back to `visibleWidth` for non-ASCII lines; `WIDTH_CACHE_SIZE` is 4096 to match. Since divergence 5 below, this check only runs for lines whose raw string reference changed since the previous frame, so the every-line-every-frame scan (and its width-cache FIFO thrash beyond 4096 distinct non-ASCII lines) no longer occurs. Guarding tests: "TUI overwide line handling" in `test/tui-render.test.ts` (exact viewport assertions) and "asciiVisibleWidth" in `test/truncate-to-width.test.ts`. 4. **`src/components/text.ts` / `markdown.ts` / `truncated-text.ts` / `editor.ts` — negative-width `repeat` guards**: the `repeat` counts for blank lines, horizontal rules, and the editor's top/bottom borders are clamped to ≥ 0 (two editor border sites; markdown's emptyLine and hr — the hr site is currently unreachable from the render entry and is purely defensive). Guarding tests: the "negative width safety" cases — Text's lives in `test/tui-render.test.ts` (Text has no dedicated test file), Markdown's and TruncatedText's live in their own test files; the editor's is "does not throw at zero or negative widths" inside the "Editor narrow width rendering" group in `test/editor.test.ts`. +5. **`src/tui.ts` — per-frame processed-line reuse**: `doRender` keeps the previous frame's raw lines (`previousRawLines`), their processed output (`previousLines`), and per-line kitty image ids (`previousLineImageIds`). A line whose raw string is reference-identical to the previous frame's reuses its processed output (truncation + `normalizeTerminalOutput` + trailing `SEGMENT_RESET`) verbatim, so a steady-state frame costs O(total lines) pointer comparisons plus O(changed lines) real work instead of re-normalizing every line. Image-id consumers (`expandChangedRangeForKittyImages`, `deleteChangedKittyImages`, the frame-end id union) read the cached per-line ids rather than re-scanning line text; upstream has no such cache and re-processes every line every frame (upstream also has `applyLineResets`, which this divergence inlines into the processed-line build). Reuse is only valid when the terminal width is unchanged; width changes re-render everything at the new width so references never match. Guarding tests: "TUI steady-frame processed-line reuse" in `test/tui-render.test.ts`. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index c578c7d93e..e4556a8f47 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -27,6 +27,9 @@ import { const KITTY_SEQUENCE_PREFIX = "\x1b_G"; +/** Shared empty id list for non-image lines in the per-line image-id cache. */ +const EMPTY_IMAGE_IDS: readonly number[] = []; + interface KittyImageHeader { ids: number[]; rows: number; @@ -305,6 +308,16 @@ export class Container implements Component { export class TUI extends Container { public terminal: Terminal; private previousLines: string[] = []; + /** + * Raw (pre-processing) lines of the previous frame, aligned with + * {@link previousLines}. Component render caches return identical string + * references for unchanged content, which lets each frame reuse the + * processed output for every untouched line instead of re-normalizing and + * re-comparing the whole transcript (see doRender). + */ + private previousRawLines: string[] = []; + /** Per-line kitty image ids of the previous frame, aligned with previousRawLines. */ + private previousLineImageIds: ReadonlyArray[] = []; private previousKittyImageIds = new Set(); private previousWidth = 0; private previousHeight = 0; @@ -1102,21 +1115,10 @@ export class TUI extends Container { private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - private applyLineResets(lines: string[]): string[] { - const reset = TUI.SEGMENT_RESET; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!; - if (!isImageLine(line)) { - lines[i] = normalizeTerminalOutput(line) + reset; - } - } - return lines; - } - - private collectKittyImageIds(lines: string[]): Set { + private unionKittyImageIds(lineImageIds: ReadonlyArray[]): Set { const ids = new Set(); - for (const line of lines) { - for (const id of extractKittyImageIds(line)) { + for (const lineIds of lineImageIds) { + for (const id of lineIds) { ids.add(id); } } @@ -1149,12 +1151,13 @@ export class TUI extends Container { firstChanged: number, lastChanged: number, newLines: string[], + newLineImageIds: ReadonlyArray[], ): { firstChanged: number; lastChanged: number } { let expandedFirstChanged = firstChanged; let expandedLastChanged = lastChanged; - const expandForLines = (lines: string[]): void => { + const expandForLines = (lines: string[], lineImageIds: ReadonlyArray[]): void => { for (let i = 0; i < lines.length; i++) { - if (extractKittyImageIds(lines[i]!).length === 0) continue; + if ((lineImageIds[i] ?? EMPTY_IMAGE_IDS).length === 0) continue; const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { expandedFirstChanged = Math.min(expandedFirstChanged, i); @@ -1163,8 +1166,8 @@ export class TUI extends Container { } }; - expandForLines(this.previousLines); - expandForLines(newLines); + expandForLines(this.previousLines, this.previousLineImageIds); + expandForLines(newLines, newLineImageIds); return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; } @@ -1174,7 +1177,7 @@ export class TUI extends Container { const ids = new Set(); const maxLine = Math.min(lastChanged, this.previousLines.length - 1); for (let i = firstChanged; i <= maxLine; i++) { - for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { + for (const id of this.previousLineImageIds[i] ?? EMPTY_IMAGE_IDS) { ids.add(id); } } @@ -1288,21 +1291,44 @@ export class TUI extends Container { // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); - // Never write a line wider than the terminal: truncate defensively - // instead of crashing. Extremely narrow terminals can make - // components overflow by a column (e.g. wide graphemes at width 1). - // applyLineResets() runs afterwards, so truncated lines still get - // their trailing reset and cannot leak styles. - for (let i = 0; i < newLines.length; i++) { - const line = newLines[i]!; - if (isImageLine(line)) continue; - const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); - if (lineWidth > width) { - newLines[i] = sliceByColumn(line, 0, width, true); + // Process raw lines for output. Never write a line wider than the + // terminal: truncate defensively instead of crashing. Extremely narrow + // terminals can make components overflow by a column (e.g. wide + // graphemes at width 1). The trailing segment reset is appended after + // truncation, so truncated lines still get their reset and cannot leak + // styles. + // + // Lines whose raw string is reference-identical to the previous frame's + // reuse their processed output verbatim: component render caches return + // the same string references for unchanged content, so a steady frame + // only pays for the lines that actually changed instead of + // re-normalizing the whole transcript. + const rawLines = newLines; + const reuseProcessed = !widthChanged && this.previousRawLines.length > 0; + const processedLines: string[] = new Array(rawLines.length); + const lineImageIds: ReadonlyArray[] = new Array(rawLines.length); + for (let i = 0; i < rawLines.length; i++) { + const rawLine = rawLines[i]!; + if (reuseProcessed && rawLine === this.previousRawLines[i]) { + processedLines[i] = this.previousLines[i]!; + lineImageIds[i] = this.previousLineImageIds[i]!; + continue; } + let line = rawLine; + let imageIds: readonly number[] = EMPTY_IMAGE_IDS; + if (isImageLine(line)) { + imageIds = extractKittyImageIds(line); + } else { + const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); + if (lineWidth > width) { + line = sliceByColumn(line, 0, width, true); + } + line = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET; + } + processedLines[i] = line; + lineImageIds[i] = imageIds; } - - newLines = this.applyLineResets(newLines); + newLines = processedLines; // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { @@ -1343,7 +1369,9 @@ export class TUI extends Container { this.previousViewportTop = Math.max(0, bufferLength - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; }; @@ -1411,7 +1439,12 @@ export class TUI extends Container { lastChanged = newLines.length - 1; } if (firstChanged !== -1) { - const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + const expandedRange = this.expandChangedRangeForKittyImages( + firstChanged, + lastChanged, + newLines, + lineImageIds, + ); firstChanged = expandedRange.firstChanged; lastChanged = expandedRange.lastChanged; } @@ -1422,6 +1455,11 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousViewportTop = prevViewportTop; this.previousHeight = height; + // Processed output is unchanged, but keep the raw/image-id caches in + // sync so future frames keep hitting the reuse fast path (e.g. the + // cursor-marker line gets a fresh string every frame). + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; return; } @@ -1467,7 +1505,9 @@ export class TUI extends Container { } this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; this.previousViewportTop = prevViewportTop; @@ -1610,7 +1650,9 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); this.previousWidth = width; this.previousHeight = height; } diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index f928f7015d..d40811d0e8 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -829,3 +829,164 @@ describe("Text negative width safety", () => { assert.doesNotThrow(() => text.render(-1)); }); }); + +describe("TUI steady-frame processed-line reuse", () => { + it("writes only the changed line when one component updates in a long transcript", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const staticComponent = new TestComponent(); + staticComponent.lines = Array.from({ length: 30 }, (_, i) => `static-${String(i).padStart(2, "0")}`); + const spinner = new TestComponent(); + spinner.lines = ["frame-a"]; + tui.addChild(staticComponent); + tui.addChild(spinner); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + spinner.lines = ["frame-b"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("frame-b"), "changed line should be written"); + assert.ok(!writes.includes("static-"), "unchanged transcript lines must not be rewritten"); + assert.ok(!writes.includes("\x1b[2J"), "no full clear for an in-viewport change"); + + tui.stop(); + }); + + it("writes nothing but the cursor hide for an identical frame", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["alpha", "beta", "gamma"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.strictEqual(writes.replaceAll("\x1b[?25l", ""), ""); + tui.stop(); + }); + + it("does not serve stale processed lines when a line toggles between two values", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["head", "value-a", "tail"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + component.lines = ["head", "value-b", "tail"]; + tui.requestRender(); + await terminal.waitForRender(); + component.lines = ["head", "value-a", "tail"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = await terminal.flushAndGetViewport(); + assert.strictEqual(viewport[0], "head"); + assert.strictEqual(viewport[1], "value-a"); + assert.strictEqual(viewport[2], "tail"); + tui.stop(); + }); + + it("fully redraws when the terminal width changes", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + component.lines = ["one", "two", "three"]; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + terminal.resize(30, 10); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("\x1b[2J"), "width change should clear and redraw"); + assert.ok(writes.includes("one") && writes.includes("two") && writes.includes("three")); + tui.stop(); + }); + + it("redraws a kitty image block when a line above it changes", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]!; + component.lines = ["header", ...imageLines, "footer"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = ["header2", ...imageLines, "footer"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("header2"), "changed line should be written"); + assert.ok( + writes.includes(imageSequence), + "image block should be redrawn when a line above it changes", + ); + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("does not rewrite a kitty image on an identical frame", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]!; + component.lines = ["header", ...imageLines, "footer"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(!writes.includes(imageSequence), "identical frame must not rewrite the image"); + assert.strictEqual(writes.replaceAll("\x1b[?25l", ""), ""); + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); +});