Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/bg-agent-terminal-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@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, and include the resume agent id and recovery instructions in the failure notification so the model can resume reliably.
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
132 changes: 127 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,90 @@ 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',
options: { errorText?: string | undefined } = {},
): void {
const phase: 'done' | 'failed' = status === 'completed' ? 'done' : 'failed';
const { errorText } = options;
const phaseUnchanged = this.backgroundTaskTerminalPhase === phase;
let errorChanged = false;
if (phase === 'failed') {
// Surface the 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`. Priority:
// 1. Explicit `errorText` from the caller (the real message from a
// live `subagent.failed` event) always wins — it is the most
// informative.
// 2. Existing `subagentError` (could be from a prior
// `onSubagentFailed` or an earlier explicit override) is kept.
// 3. Fall back to a friendly generic so the failure has SOME
// visible explanation when no source has supplied one.
if (errorText !== undefined && this.subagentError !== errorText) {
this.subagentError = errorText;
errorChanged = true;
} else if (this.subagentError === undefined) {
const generic = backgroundFailureMessage(status);
if (generic !== undefined) {
this.subagentError = generic;
errorChanged = true;
}
}
}
if (phaseUnchanged && !errorChanged) return;
this.backgroundTaskTerminalPhase = phase;
this.subagentEndedAtMs ??= Date.now();
this.syncSubagentElapsedTimer();
this.headerText.setText(this.buildHeader());
this.rebuildContent();
this.notifySnapshotChange();
}

/**
* Subagent id for the backing AgentTool call, used by routing to find a
* tool call's backing subagent when reconciling background task lifecycle
* events.
*
* Two writers, in priority order:
* 1. In-memory `subagentAgentId` — wired by `setSubagentMeta` /
* `onSubagentSpawned` for foreground agents. For backgrounded agents
* this stays undefined: `handleSubagentSpawned` early-returns before
* calling `tc.onSubagentSpawned`, and `applySubagentReplay` early-
* returns when the wire payload omits the `subagent` block — which
* it does for every replayed Agent call.
* 2. The spawn-success ToolResult body — AgentTool unconditionally
* emits `agent_id: agent-N` for every Agent call (foreground and
* background). Parsing it gives the stable identifier even when the
* in-memory field is empty, which is the only way the resume path
* can reliably route a `background.task.terminated` to the right
* card and the only way the live path avoids matching by description
* and accidentally updating an unrelated Agent card that happens to
* share the same `args.description`.
*/
getSubagentAgentId(): string | undefined {
if (this.subagentAgentId !== undefined) return this.subagentAgentId;
if (this.toolCall.name !== 'Agent' || this.result === undefined) return undefined;
const match = this.result.output.match(/^agent_id:\s*(agent-[A-Za-z0-9_-]+)/m);
return match?.[1];
}

/** `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 +1267,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 +1391,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 +1407,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
22 changes: 22 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 @@ -754,6 +754,17 @@ export class SessionEventHandler {
if (backgroundMeta !== undefined) {
this.backgroundAgentMetadata.delete(event.subagentId);
this.syncBackgroundAgentBadge();
// Push the real subagent error onto the parent Agent card too —
// `background.task.terminated` arrives separately (possibly later)
// with no error string and would only stamp the generic
// `Background agent failed`. The card and the separate transcript
// entry now share the same actual reason.
streamingUI.applyBackgroundTaskTerminalStatus({
agentId: event.subagentId,
description: backgroundMeta.description ?? '',
status: 'failed',
errorText: event.error,
});
const taskId = this.findAgentTaskId(event.subagentId);
if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) {
return;
Expand Down Expand Up @@ -872,6 +883,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
91 changes: 91 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,97 @@ 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 policy: an `args.agentId` is treated as authoritative — we
* either find a card whose `getSubagentAgentId()` returns the same id
* (in-memory metadata for live foreground, parsed from the spawn-success
* `agent_id: ...` line for live backgrounded and replayed cards) or we
* skip. We deliberately do NOT fall back to description match when
* `agentId` is provided, because:
* - On resume, `applyTerminalBackgroundAgentStatuses` iterates every
* persisted terminal task, including ones whose tool calls fell
* outside the `REPLAY_TURN_LIMIT` window. A description fallback
* would let an old `lost` task stamp its status onto an unrelated
* recent Agent card that happens to share `args.description`.
* - During a live spawn / terminate race, the same card can briefly
* appear in both `_pendingToolComponents` and `transcriptContainer`,
* so a description match could double-visit the same component and
* mark itself ambiguous. agentId match short-circuits on the first
* hit and is immune.
*
* Description fallback is kept as a best-effort path only when
* `agentId` is unknown — that is, on resume of pre-PR sessions whose
* disk records pre-date `agent_id` persistence.
*
* 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';
/**
* Real failure message to surface on the card. Pass the `subagent.failed`
* event's `error` for live crashes — it is far more useful than the
* friendly generic the card falls back to. Omit on the resume / terminate
* path where no real error is available.
*/
errorText?: string | undefined;
}): boolean {
const useAgentIdOnly = args.agentId !== undefined;
let agentIdMatch: ToolCallComponent | undefined;
let descMatch: ToolCallComponent | undefined;
let descAmbiguous = false;
const visit = (tc: ToolCallComponent): void => {
if (agentIdMatch !== undefined) return;
if (useAgentIdOnly) {
if (tc.getSubagentAgentId() === args.agentId) 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 = useAgentIdOnly
? agentIdMatch
: descAmbiguous
? undefined
: descMatch;
if (target === undefined) return false;
target.setBackgroundTaskTerminalStatus(args.status, { errorText: args.errorText });
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