Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/bg-agent-terminal-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show the real terminal status of background agents in the transcript so lost, failed, and killed ones no longer appear as completed.
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/components/messages/agent-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export class AgentGroupComponent extends Container {
return this.entries.length;
}

/**
* Exposes the borrowed tool call components so external code (e.g.
* routing background task terminal events back to the corresponding
* Agent card) can reach them — the group renders the tcs' snapshots
* but never mounts the tcs as Container children, so a plain tree
* walk of `transcriptContainer` cannot discover them.
*/
getToolComponents(): readonly ToolCallComponent[] {
return this.entries.map((entry) => entry.tc);
}

/**
* Borrows a standalone `ToolCallComponent` into the group as a hidden state
* container. Snapshot changes trigger throttled refreshes. Re-attaching the
Expand Down
89 changes: 84 additions & 5 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ export interface ToolCallReadSnapshot {
readonly lines: number;
}

function backgroundFailureMessage(
status: 'completed' | 'failed' | 'killed' | 'lost' | undefined,
): string | undefined {
switch (status) {
case 'lost':
return 'Background agent lost (session restarted before completion)';
case 'killed':
return 'Background agent killed';
case 'failed':
return 'Background agent failed';
case 'completed':
case undefined:
return undefined;
}
}

function str(v: unknown): string {
return typeof v === 'string' ? v : '';
}
Expand Down Expand Up @@ -474,6 +490,17 @@ export class ToolCallComponent extends Container {
private subagentThinkingText = '';
// ── Subagent lifecycle state from subagent.spawned/completed/failed ──
private subagentPhase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined;
/**
* Authoritative terminal phase for a backgrounded subagent. Set from
* `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once
* the backing task reaches a terminal state — either live (a bg agent
* fails / is killed) or on resume (reconcile reclassifies a still-running
* task as `lost`). Beats the spawn-success ToolResult in both render
* paths (`getDerivedSubagentPhase` for standalone, `getSubagentSnapshot`
* for grouped), which would otherwise mislabel every terminated
* background agent — including lost ones — as `✓ Completed`.
*/
private backgroundTaskTerminalPhase: 'done' | 'failed' | undefined;
private subagentContextTokens: number | undefined;
private subagentUsage: TokenUsage | undefined;
private subagentResultSummary: string | undefined;
Expand Down Expand Up @@ -707,7 +734,14 @@ export class ToolCallComponent extends Container {
// `backgrounded` has no result because background agents do not enter the
// transcript.
const derivedPhase: ToolCallSubagentSnapshot['phase'] =
this.result !== undefined ? (this.result.is_error ? 'failed' : 'done') : this.subagentPhase;
this.backgroundTaskTerminalPhase ??
(this.result !== undefined
? this.result.is_error
? 'failed'
: 'done'
: this.subagentPhase);
const errorText =
this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined);
return {
toolCallId: this.toolCall.id,
toolName: this.toolCall.name,
Expand All @@ -717,8 +751,7 @@ export class ToolCallComponent extends Container {
toolCount: finished,
tokens,
isError: derivedPhase === 'failed',
errorText:
this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined),
errorText,
latestActivity,
};
}
Expand Down Expand Up @@ -934,6 +967,47 @@ export class ToolCallComponent extends Container {
this.ui?.requestRender();
}

/**
* Records the actual terminal status of the backing background task so
* the snapshot phase no longer relies on the spawn-success ToolResult.
* Called for `agent-*` background tasks both live (when the bg agent
* terminates non-successfully) and on resume (when reconcile
* reclassifies a previously-running task as `lost`).
*/
setBackgroundTaskTerminalStatus(status: 'completed' | 'failed' | 'killed' | 'lost'): void {
const phase: 'done' | 'failed' = status === 'completed' ? 'done' : 'failed';
if (this.backgroundTaskTerminalPhase === phase) return;
this.backgroundTaskTerminalPhase = phase;
// Surface a friendly failure line through the same `subagentError` slot
// that `onSubagentFailed` writes. The standalone card reads this in
// `buildSingleSubagentBlock`; the group card reads it via `errorText` in
// `getSubagentSnapshot`. A pre-existing real error from a prior
// `onSubagentFailed` event is more informative — keep it.
if (phase === 'failed' && this.subagentError === undefined) {
this.subagentError = backgroundFailureMessage(status);
}
this.subagentEndedAtMs ??= Date.now();
this.syncSubagentElapsedTimer();
this.headerText.setText(this.buildHeader());
this.rebuildContent();
this.notifySnapshotChange();
Comment on lines +1009 to +1014

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 Rebuild standalone Agent cards after terminal status

When the matched Agent tool call is still a standalone ToolCallComponent (for example, a replay with only one background Agent call in the step, so it was never upgraded to AgentGroupComponent), this method only updates the snapshot listener state. The standalone render path uses getDerivedSubagentPhase(), which still derives done from the non-error spawn-success ToolResult, and no header/content rebuild is requested here, so the visible card continues to show Completed for lost/failed/killed tasks. The new status override only affects grouped Agent cards that consume getSubagentSnapshot().

Useful? React with 👍 / 👎.

}

/** Spawned subagent id, if any. Used by routing to find a tool call's
* backing subagent when reconciling background task lifecycle events. */
getSubagentAgentId(): string | undefined {
return this.subagentAgentId;
}

/** `args.description` for `Agent` tool calls, used as a resume-path
* fallback when the wire format pre-dates persisted subagent ids and
* the only stable cross-restart identifier is the description string. */
getAgentToolDescription(): string | undefined {
if (this.toolCall.name !== 'Agent') return undefined;
const desc = this.toolCall.args['description'];
return typeof desc === 'string' ? desc : undefined;
}

appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void {
if (kind === 'thinking') {
this.subagentThinkingText += text;
Expand Down Expand Up @@ -1150,7 +1224,8 @@ export class ToolCallComponent extends Container {
this.ongoingSubCalls.size === 0 &&
this.finishedSubCalls.length === 0 &&
this.subagentText.length === 0 &&
this.subagentPhase === undefined
this.subagentPhase === undefined &&
this.backgroundTaskTerminalPhase === undefined
) {
return;
}
Expand Down Expand Up @@ -1273,7 +1348,8 @@ export class ToolCallComponent extends Container {
this.subToolActivities.size > 0 ||
this.subagentText.length > 0 ||
this.subagentThinkingText.length > 0 ||
this.subagentPhase !== undefined
this.subagentPhase !== undefined ||
this.backgroundTaskTerminalPhase !== undefined
);
}

Expand All @@ -1288,6 +1364,9 @@ export class ToolCallComponent extends Container {
| 'failed'
| 'backgrounded'
| undefined {
if (this.backgroundTaskTerminalPhase !== undefined) {
return this.backgroundTaskTerminalPhase;
}
if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done';
return this.subagentPhase;
}
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,17 @@ export class SessionEventHandler {
}

if (event.type === 'background.task.terminated' && isTerminal) {
if (info.taskId.startsWith('agent-')) {
// The Agent tool's spawn-success ToolResult is not an error, so the
// parent toolCall card would otherwise render `✓ Completed` for any
// terminated bg agent — including `lost` / `failed` / `killed`.
// Push the actual terminal status so the card matches reality.
this.host.streamingUI.applyBackgroundTaskTerminalStatus({
agentId: info.agentId,
description: info.description,
status: info.status,
});
}
if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) {
if (info.taskId.startsWith('bash-')) {
this.appendBackgroundTaskEntry(info);
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class SessionReplayRenderer {

this.hydrateSnapshot(main);
this.renderRecords(main);
this.applyTerminalBackgroundAgentStatuses(main);
return true;
} catch (error) {
const message = formatErrorMessage(error);
Expand Down Expand Up @@ -104,6 +105,36 @@ export class SessionReplayRenderer {
this.host.streamingUI.setTodoList(todos);
}

/**
* Push real terminal status into each replayed `Agent` card whose
* backing background task is already in a terminal state. Runs AFTER
* `renderRecords` because the tool call components only exist once the
* replay has mounted them — `hydrateBackgroundState` runs too early to
* reach them. Without this, terminated bg agents (including ones that
* reconcile reclassified as `lost`) keep the spawn-success ToolResult's
* default of `✓ Completed`.
*/
private applyTerminalBackgroundAgentStatuses(agent: ResumedAgentState): void {
for (const info of agent.background) {
if (!info.taskId.startsWith('agent-')) continue;
if (!isTerminalBackgroundTask(info)) continue;
const status = info.status;
if (
status !== 'completed' &&
status !== 'failed' &&
status !== 'killed' &&
status !== 'lost'
) {
continue;
}
this.host.streamingUI.applyBackgroundTaskTerminalStatus({
agentId: info.agentId,
description: info.description,
Comment on lines +130 to +132

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 Limit replay status hydration to the rendered task

On resume this loops over every persisted terminal background task, but renderRecords only mounts the last REPLAY_TURN_LIMIT turns and persisted agent tasks often arrive without agentId, forcing the description fallback. If an older terminal background agent outside the replay window shares a description with the single rendered Agent card, this call applies the old task's lost/failed/completed status to the unrelated recent card, so the transcript can show a wrong terminal state.

Useful? React with 👍 / 👎.

status,
});
}
}

private hydrateBackgroundState(agent: ResumedAgentState): void {
const { state, sessionEventHandler } = this.host;
const projection = replayBackgroundProjection(agent.background);
Expand Down
68 changes: 68 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,74 @@ export class StreamingUIController {
}
}

/**
* Push the actual terminal status of a background agent task into the
* matching `Agent` tool call component so its snapshot phase no longer
* trusts the spawn-success ToolResult (which would otherwise label every
* terminated bg agent — including `lost` ones — as `✓ Completed`).
*
* Resolution order, picked to handle both live and resume:
* 1. `agentId` → matches the subagent id recorded on `spawned`
* (live path; replayed tool calls currently do not carry a
* subagent id back, so this branch only catches live mismatches).
* 2. Description fallback — required on resume because reconcile's
* `BackgroundTaskInfo` is the only handle the TUI has to a
* replayed Agent card. Only used when exactly one Agent tool call
* shares the description; ambiguous matches are skipped to avoid
* corrupting an unrelated card.
*
* Search scope includes both in-flight components and already-mounted
* cards (some live in `transcriptContainer` standalone, others are
* borrowed by an `AgentGroupComponent` and reachable only via
* `getToolComponents()`).
*
* Returns true iff a component was found and updated.
*/
applyBackgroundTaskTerminalStatus(args: {
agentId?: string | undefined;
description: string;
status: 'completed' | 'failed' | 'killed' | 'lost';
}): boolean {
let agentIdMatch: ToolCallComponent | undefined;
let descMatch: ToolCallComponent | undefined;
let descAmbiguous = false;
const visit = (tc: ToolCallComponent): void => {
if (agentIdMatch !== undefined) return;
if (args.agentId !== undefined && tc.getSubagentAgentId() === args.agentId) {

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 Record agent ids before matching live background cards

When two live background Agent calls use the same description, this agentId branch will not actually disambiguate them: handleSubagentSpawned returns for event.runInBackground before calling tc.onSubagentSpawned (session-event-handler.ts:698-703), so the corresponding ToolCallComponent never stores subagentAgentId. The lookup then falls through to the description match, marks it ambiguous, and skips applying the terminal status, leaving both cards with the spawn-success Completed state that this change is meant to fix.

Useful? React with 👍 / 👎.

agentIdMatch = tc;
return;
}
if (tc.getAgentToolDescription() !== args.description) return;
if (descMatch !== undefined) {
descAmbiguous = true;
return;
}
descMatch = tc;
};

for (const tc of this._pendingToolComponents.values()) {
visit(tc);
if (agentIdMatch !== undefined) break;
}
if (agentIdMatch === undefined) {
for (const child of this.host.state.transcriptContainer.children) {
if (child instanceof ToolCallComponent) {
visit(child);
Comment on lines +246 to +248

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 De-duplicate pending cards before declaring ambiguity

When a background Agent terminates before its spawn-success tool.result has cleaned up _pendingToolComponents, the same standalone card is present both in _pendingToolComponents and in transcriptContainer. If there is no agentId match yet, the pending pass records descMatch, then this transcript pass visits the same object again and sets descAmbiguous, so the terminal status is dropped even though only one card exists; the later spawn-success result can then leave a failed/lost fast-starting background agent rendered as completed. Track visited components or skip the already-seen pending card before treating a second description match as ambiguous.

Useful? React with 👍 / 👎.

} else if (child instanceof AgentGroupComponent) {
for (const tc of child.getToolComponents()) {
visit(tc);
if (agentIdMatch !== undefined) break;
}
}
if (agentIdMatch !== undefined) break;
}
}
const target = agentIdMatch ?? (descAmbiguous ? undefined : descMatch);
if (target === undefined) return false;
target.setBackgroundTaskTerminalStatus(args.status);
return true;
}

/** Registers a tool call that arrived via tool.call.started.
* Clears any pending streaming state for this id, updates or creates the
* component, and returns whether the call was new (no previous entry). */
Expand Down
Loading
Loading