Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/task-notification-fold-swallows-answer.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
48 changes: 44 additions & 4 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,6 +161,7 @@ export class SessionEventHandler {
// Runtime state – owned by this handler, reset between sessions.
backgroundTasks: Map<string, BackgroundTaskInfo> = new Map();
backgroundTaskTranscriptedTerminal: Set<string> = new Set();
private backgroundTaskTerminalEntries: Map<string, TranscriptEntry> = new Map();

renderedSkillActivationIds: Set<string> = new Set();
renderedPluginCommandActivationIds: Set<string> = new Set();
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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(),
Expand All @@ -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 });
Comment on lines +1308 to +1312

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the terminal card emitted by the subagent handler

When a detached agent completes, SubAgentEventHandler.handleSubagentCompleted can append its terminal card first and mark backgroundTaskTranscriptedTerminal; the later background.task.terminated event then skips its append, so backgroundTaskTerminalEntries is never populated. When the task-notification turn starts, this fallback appends a second terminal card even though the first remains mounted. Record the entry created through the subagent path, or locate/reuse that existing entry, to avoid duplicate completion cards for normal background-agent notifications.

Useful? React with 👍 / 👎.

this.backgroundTaskTerminalEntries.set(taskId, entry);
this.backgroundTaskTranscriptedTerminal.add(taskId);
}

private syncBackgroundTaskBadge(): void {
Expand Down
43 changes: 39 additions & 4 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
);
Comment thread
kimi-agent-bot marked this conversation as resolved.
}

private trimTranscriptWindow(): boolean {
Expand Down
167 changes: 167 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 27 additions & 5 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 =
'<cron-fire jobId="job-2" missed="true" count="3">\n3 one-shot tasks missed while offline\n</cron-fire>';
Expand Down
Loading