diff --git a/.changeset/task-notification-fold-swallows-answer.md b/.changeset/task-notification-fold-swallows-answer.md new file mode 100644 index 00000000000..4a045ef5207 --- /dev/null +++ b/.changeset/task-notification-fold-swallows-answer.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the latest reply disappearing from the transcript after a background task completion notification arrives; the fold boundary is now anchored to the notification turn itself instead of the asynchronously mounted task card. diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index 9c1a3d815b3..aca406c4f87 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -4,11 +4,15 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; -import type { BackgroundAgentStatusData } from '#/tui/types'; +import type { BackgroundAgentStatusData, BackgroundAgentStatusPhase } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { constructor(private readonly data: BackgroundAgentStatusData) {} + get phase(): BackgroundAgentStatusPhase { + return this.data.phase; + } + invalidate(): void {} render(width: number): string[] { 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 355f5a537b9..53ff8ddb6c6 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -116,6 +116,7 @@ export interface SessionEventHost { restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + moveTranscriptEntryToEnd(entry: TranscriptEntry): boolean; handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; @@ -160,6 +161,7 @@ export class SessionEventHandler { // Runtime state – owned by this handler, reset between sessions. backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); + private backgroundTaskTerminalEntries: Map = new Map(); renderedSkillActivationIds: Set = new Set(); renderedPluginCommandActivationIds: Set = new Set(); @@ -180,6 +182,7 @@ export class SessionEventHandler { resetRuntimeState(): void { this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); + this.backgroundTaskTerminalEntries.clear(); this.subAgentEventHandler.resetRuntimeState(); this.notifications.reset(); this.renderedSkillActivationIds.clear(); @@ -334,6 +337,24 @@ export class SessionEventHandler { if (event.origin?.kind === 'plugin_command') { this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); } + // The v2 engine emits task-notification turns with a `task` origin the SDK + // union predates, so read it structurally. The turn must open with its own + // fold-segment boundary: the terminal card mounted when the task finished + // sits at an arbitrary (possibly mid-turn) position and cannot serve as one. + const taskOrigin = event.origin as + | { + readonly kind?: string; + readonly taskId?: string; + readonly status?: BackgroundTaskInfo['status']; + } + | undefined; + if ( + taskOrigin?.kind === 'task' && + taskOrigin.taskId !== undefined && + taskOrigin.status !== undefined + ) { + this.anchorTaskNotificationTurn(taskOrigin.taskId, taskOrigin.status); + } this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); @@ -1244,9 +1265,7 @@ export class SessionEventHandler { } } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { - if (info.kind === 'process' || info.kind === 'question') { - this.appendBackgroundTaskEntry(info); - } + this.backgroundTaskTerminalEntries.set(info.taskId, this.appendBackgroundTaskEntry(info)); this.backgroundTaskTranscriptedTerminal.add(info.taskId); } this.syncBackgroundTaskBadge(); @@ -1260,7 +1279,7 @@ export class SessionEventHandler { this.host.tasksBrowserController.repaint(); } - private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): TranscriptEntry { const status = formatBackgroundTaskTranscript(info); const entry: TranscriptEntry = { id: nextTranscriptId(), @@ -1272,6 +1291,27 @@ export class SessionEventHandler { backgroundAgentStatus: status, }; this.host.appendTranscriptEntry(entry); + return entry; + } + + /** + * Give a task-notification turn its own fold-segment boundary at turn start: + * move the task's asynchronously mounted terminal card down to the turn, + * or mount one now when none is mounted (e.g. it was trimmed). Without an + * anchor the turn's end-of-turn fold would extend into the previous turn's + * output and collapse its final answer into the step summary. + */ + private anchorTaskNotificationTurn( + taskId: string, + status: BackgroundTaskInfo['status'], + ): void { + const mounted = this.backgroundTaskTerminalEntries.get(taskId); + if (mounted !== undefined && this.host.moveTranscriptEntryToEnd(mounted)) return; + const task = this.backgroundTasks.get(taskId); + if (task === undefined) return; + const entry = this.appendBackgroundTaskEntry({ ...task, status }); + this.backgroundTaskTerminalEntries.set(taskId, entry); + this.backgroundTaskTranscriptedTerminal.add(taskId); } private syncBackgroundTaskBadge(): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 92e8e12ab27..69e7375b5b9 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2877,6 +2877,32 @@ export class KimiTUI { } } + /** + * Relocate an already-mounted transcript entry (and its component) to the + * end of the transcript. Used to re-anchor an asynchronously mounted + * background-task terminal card to the start of its notification turn, so + * the card's fold-segment boundary guards that turn's output instead of + * splitting whatever turn happened to be live when the task terminated. + * Returns false when the entry is no longer mounted (e.g. trimmed). + */ + moveTranscriptEntryToEnd(entry: TranscriptEntry): boolean { + const entries = this.state.transcriptEntries; + const entryIndex = entries.indexOf(entry); + if (entryIndex < 0) return false; + entries.splice(entryIndex, 1); + entries.push(entry); + const children = this.state.transcriptContainer.children; + const childIndex = children.findIndex( + (child) => getTranscriptComponentEntry(child) === entry, + ); + if (childIndex >= 0) { + const [component] = children.splice(childIndex, 1); + children.push(component!); + } + this.state.ui.requestRender(); + return true; + } + private appendApprovalTranscriptEntry( request: ApprovalRequest, response: ApprovalResponse, @@ -2979,13 +3005,22 @@ export class KimiTUI { /** * Fold-segment boundary: everything {@link isTurnBoundaryComponent} counts, - * plus the cron card. A cron-fired turn mounts no user message, so without - * the card as a boundary its output would share the previous user turn's + * plus the cron card and terminal background-task cards. Cron-fired and + * task-notification turns mount no user message, so without one of these + * cards as a boundary their output would share the previous user turn's * fold segment — and the completed-turn assistant cap would fold that turn's - * final answer into the step summary. + * final answer into the step summary. A task-notification turn re-anchors + * its terminal card to the turn's start (see SessionEventHandler), because + * the card's asynchronous mount point may sit inside an unrelated turn; + * cards that never get a notification turn (resumed sessions, missed + * drains) still bound whatever segment they landed in. */ private isFoldSegmentBoundaryComponent(child: Component): boolean { - return this.isTurnBoundaryComponent(child) || child instanceof CronMessageComponent; + return ( + this.isTurnBoundaryComponent(child) || + child instanceof CronMessageComponent || + (child instanceof BackgroundAgentStatusComponent && child.phase !== 'started') + ); } private trimTranscriptWindow(): boolean { 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 c7985d17d38..663efb9f33f 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 @@ -4561,6 +4561,173 @@ command = "vim" expect(transcript).toContain('cron report final'); }); + it('keeps the previous turn’s final answer mounted when a task-notification turn completes', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `task-fold-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working on it', '1'); + entry('assistant', 'FINAL-ANSWER', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + expect(stripSgr(renderTranscript(driver))).toContain('FINAL-ANSWER'); + + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId: 'task-1', + kind: 'process', + description: 'nightly sync', + status: 'completed', + exitCode: 0, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + const taskOrigin = { + kind: 'task', + taskId: 'task-1', + status: 'completed', + notificationId: 'ntf-1', + }; + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin } as Event); + entry('assistant', 'task report part one', '2'); + entry('assistant', 'task report final', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('task report final'); + expect(transcript).toContain('FINAL-ANSWER'); + }); + + it('keeps the previous turn’s final answer mounted when a background-agent notification turn completes', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `agent-task-fold-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working on it', '1'); + entry('assistant', 'FINAL-ANSWER', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId: 'task-9', + kind: 'agent', + agentId: 'agent-9', + description: 'scout the fleet', + status: 'completed', + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + const taskOrigin = { + kind: 'task', + taskId: 'task-9', + status: 'completed', + notificationId: 'ntf-9', + }; + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin } as Event); + entry('assistant', 'agent report part one', '2'); + entry('assistant', 'agent report final', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('agent report final'); + expect(transcript).toContain('FINAL-ANSWER'); + }); + + it('anchors the fold boundary to the notification turn when another task terminates mid-turn', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `task-anchor-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + const terminate = (taskId: string) => + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId, + kind: 'process', + description: `task ${taskId}`, + status: 'completed', + exitCode: 0, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + const taskOrigin = (taskId: string) => ({ + kind: 'task', + taskId, + status: 'completed', + notificationId: `ntf-${taskId}`, + }); + + entry('user', 'first question'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working', '1'); + entry('assistant', 'USER-FINAL', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + // task 1 terminates while idle; its notification turn opens and starts + // answering, then task 2 terminates mid-turn and its terminal card lands + // inside turn 2's output. + terminate('task-1'); + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin('task-1') } as Event); + entry('assistant', 'report one intro', '2'); + terminate('task-2'); + entry('assistant', 'REPORT-ONE-FINAL', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + // task 2's notification missed turn 2's last drain, so it opens its own + // turn. When that turn ends, the fold must start at this turn's boundary — + // not at task 2's card mounted mid-turn-2 — or REPORT-ONE-FINAL folds away. + emit({ type: 'turn.started', agentId: 'main', turnId: 3, origin: taskOrigin('task-2') } as Event); + entry('assistant', 'report two intro', '3'); + entry('assistant', 'report two middle', '3'); + entry('assistant', 'REPORT-TWO-FINAL', '3'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 3, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('REPORT-ONE-FINAL'); + expect(transcript).toContain('REPORT-TWO-FINAL'); + }); + it('coalesces assistant delta component updates', async () => { vi.useFakeTimers(); try { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 77fdeb6fecc..4db6f5735f4 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -966,11 +966,12 @@ describe('KimiTUI resume message replay', () => { ).toBe(false); expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('task-bg-timeout')) .toBe(true); - expect( - driver.state.transcriptEntries.some( - (entry) => entry.backgroundAgentStatus?.phase === 'failed', - ), - ).toBe(false); + const terminalCards = driver.state.transcriptEntries.filter( + (entry) => entry.backgroundAgentStatus !== undefined, + ); + expect(terminalCards.map((entry) => entry.backgroundAgentStatus?.headline)).toEqual([ + 'agent task timed out', + ]); }); it('renders replayed bash background notifications as bash tasks', async () => { @@ -1140,6 +1141,27 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('real answer'); }); + it('keeps the previous turn’s final answer visible when a task-notification turn follows in replay', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'real prompt' }]), + message('assistant', [{ type: 'text', text: 'real answer' }]), + message('user', [{ type: 'text', text: 'task finished' }], { + origin: { + kind: 'task', + taskId: 'task-1', + status: 'completed', + notificationId: 'ntf-1', + }, + }), + message('assistant', [{ type: 'text', text: 'task report part one' }]), + message('assistant', [{ type: 'text', text: 'task report final' }]), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('task report final'); + expect(transcript).toContain('real answer'); + }); + it('renders cron_missed origin records during replay without exposing raw XML', async () => { const cronMissed = '\n3 one-shot tasks missed while offline\n';