Skip to content

fix(tui): show real terminal status for background agents - #197

Merged
RealKai42 merged 6 commits into
mainfrom
fix/tui-bg-agent-terminal-status
May 29, 2026
Merged

fix(tui): show real terminal status for background agents#197
RealKai42 merged 6 commits into
mainfrom
fix/tui-bg-agent-terminal-status

Conversation

@RealKai42

Copy link
Copy Markdown
Collaborator

Related Issue

No prior issue. Discovered while debugging "main agent doesn't receive notifications after a group of background agents finish" — the underlying notification chain turned out to be fine, but the TUI was masking real outcomes behind a green ✓ Completed.

Problem

Agent tool calls with run_in_background=true return a ToolResult whose body says status: running and is not flagged as an error. The transcript card derives its done/failed badge from this.result, so every terminated background agent — including ones reconcile reclassifies as lost on resume — keeps the green ✓ Completed label even when the actual task failed, was killed, or never came back. On resume this is the user's primary signal that the spawned work finished, and it lies.

Reproduction: spawn 2+ background agents, kill the CLI mid-run, restart with -C. Disk shows status: "lost" for the tasks. The transcript shows ✗ Failed · Background agent lost (session restarted before completion). Before this PR, the same scenario showed ✓ Completed.

What changed

  • Override the snapshot phase in the Agent tool call card from BackgroundTaskInfo.status (new setBackgroundTaskTerminalStatus), so terminated bg agents render ✓ Completed only when the backing task actually completed.
  • Carry the raw status alongside the mapped phase and use it to produce a meaningful failure message ("Background agent lost (session restarted before completion)", etc.) instead of leaking the spawn-success ToolResult body as the error line.
  • Wire background.task.terminated events through StreamingUIController.applyBackgroundTaskTerminalStatus, which resolves the target card by subagent id first (live path) and falls back to a unique-description match (resume path). The lookup walks pending tool components, transcript children, and the components borrowed by AgentGroupComponent.
  • On resume, SessionReplayRenderer re-applies the status of already-terminal bg agents after renderRecords mounts the cards — hydrateBackgroundState runs too early to reach them, and reconcile's events have already fired before the TUI subscribes.

No agent-core semantics changed. The silent-on-resume notification behavior (restoreBackgroundTaskNotification uses appendUserMessage, not steer) is intentional and left as-is.

Test plan

  • tool-call.test.ts: 6 new unit tests pin the legacy result-only derivation and assert every BackgroundTaskInfo.status maps to the correct snapshot phase and error text. Also pins order-independence (status applied before result lands).
  • bg-idle-notification-repro.test.ts (new, agent-core): full chain regression on a real Agent instance — idle single, busy single, idle × N group, after-turn-end race, and resume silent-inject. Pins both the working behavior and the deliberate resume-no-auto-turn behavior so future changes have to acknowledge them.
  • Manual: tmux + real LLM, spawned 2 sleep 60 bg agents, killed the CLI, restarted with -C. After fix: ✗ Failed · Background agent lost (session restarted before completion). Before fix: ✓ Completed.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran `gen-changesets` skill, or this PR needs no changeset.
  • Ran `gen-docs` skill, or this PR needs no doc update.

The Agent tool's run_in_background=true call returns a non-error
ToolResult whose body just says "status: running". The transcript
card derived its done/failed badge from that result, so every
terminated background agent — including ones reconcile reclassifies
as lost on resume — kept the green "✓ Completed" label even when
the actual task failed, was killed, or never came back.

Push the real BackgroundTaskInfo.status into the matching Agent
card so the badge reflects what happened. The card's resolver
prefers subagent agentId (live) and falls back to the description
on resume; on resume the apply step also runs after replay
finishes so the agent group can reach the borrowed components.

Also adds an agent-core regression test that pins live, busy,
group, race, and resume scenarios for the bg notification chain.
@changeset-bot

changeset-bot Bot commented May 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0e190b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@moonshot-ai/agent-core Patch
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented May 29, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@e0e190b
npx https://pkg.pr.new/@moonshot-ai/kimi-code@e0e190b

commit: e0e190b

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5337ccdfac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +998 to +1002
this.backgroundTaskTerminalPhase = phase;
this.backgroundTaskTerminalStatus = status;
this.subagentEndedAtMs ??= Date.now();
this.syncSubagentElapsedTimer();
this.notifySnapshotChange();

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 👍 / 👎.

Standalone Agent cards (only one Agent tool call in a step, never
upgraded into an AgentGroupComponent) bypassed the previous
`setBackgroundTaskTerminalStatus` path: the standalone header reads
`getDerivedSubagentPhase`, which still derived `done` from the
non-error spawn-success ToolResult, and the method did not request
a header/content rebuild. Lost/failed/killed bg agents in this
shape still rendered as `✓ Completed`.

Thread the override through `getDerivedSubagentPhase`, populate
`subagentError` with the friendly failure message so both render
paths share one source of truth, and trigger the same header +
content rebuild that `onSubagentFailed` does. Also include the
override in `hasSubagentState` / the subagent-block early-return
so a replayed solo bg agent (no replayed subagent block, no
sub-tool activity) switches to the subagent-aware layout instead
of the generic `Used Agent` rendering.

Adds two standalone-render regression tests so the path no longer
relies on the grouped snapshot to stay correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9849cc93f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

Comment on lines +130 to +132
this.host.streamingUI.applyBackgroundTaskTerminalStatus({
agentId: info.agentId,
description: info.description,

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 👍 / 👎.

A backgrounded subagent that ends as `lost`/`failed`/`killed` is
already a soft-recoverable thing — `subagentHost.resume` will
reanimate the persisted Agent instance — but the LLM had to dig
through the original spawn-success ToolResult to find the right id
and figure out the recovery shape on its own. The two look-alike
identifiers (the BackgroundManager `task_id` aka `source_id`, and
the `subagentHost` `agent_id`) regularly got confused in practice.

Surface what the model needs at the moment of decision:

  - Add `agent_id` as a top-level `<notification>` attribute for
    agent-* tasks, so the right id is structural, not buried in
    prose. Render path keeps backward-compat by omitting the
    attribute when no agent_id is known (bash tasks, old sessions).
  - On non-success agent terminal states, append a recovery
    paragraph to the body: the precise `Agent(resume=...)` call,
    the disambiguation between `agent_id` and `source_id`, the
    `run_in_background` option, and what state survives the
    restart vs. what may need to be redone.
  - Tighten the spawn-time `resume_hint` with the same
    disambiguation and an explicit pointer at the
    `task.lost`/`task.failed`/`task.killed` recovery trigger.
  - Persist `agent_id` and `subagent_type` in PersistedTask so the
    recovery body still works after a session restart, where
    in-memory `BackgroundTaskInfo.agentId` would otherwise be
    undefined. Optional fields keep the disk schema
    forward/backward compatible — pre-PR records load without
    them and silently fall back to the original short body.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a80e8f76e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +227 to +229
for (const child of this.host.state.transcriptContainer.children) {
if (child instanceof ToolCallComponent) {
visit(child);

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 👍 / 👎.

RealKai42 added 3 commits May 29, 2026 15:32
…ription

`tc.subagentAgentId` is left undefined for every backgrounded agent.
`handleSubagentSpawned` early-returns for `runInBackground` before
calling `tc.onSubagentSpawned`, and the wire replay path drops the
`subagent` block entirely (`toolCallFromReplayMessage` returns only
id/name/args). So the `agentId` branch in
`applyBackgroundTaskTerminalStatus` never matched in practice, every
call fell through to the description-based fallback, and the
persisted `agent_id` we added in the previous commit was effectively
dead. That fallback also has a real failure mode: if a foreground
Agent and a backgrounded Agent share the same `args.description`,
the only candidate found is the live (unrelated) card, which gets
incorrectly relabeled as the lost task's terminal state.

Parse `agent_id: agent-N` out of the AgentTool spawn-success
ToolResult body inside `getSubagentAgentId` so the id is always
recoverable, regardless of whether the in-memory subagent metadata
was ever populated. Foreground and backgrounded Agent cards now
carry distinct ids and route correctly.

Also pipe the real `subagent.failed` error through to the parent
card. The background branch of `handleSubagentFailed` previously
only appended the dedicated transcript entry; the parent Agent
card was left with the generic "Background agent failed" written
by the later `background.task.terminated` event. Add an optional
`errorText` to `setBackgroundTaskTerminalStatus` /
`applyBackgroundTaskTerminalStatus` and pass `event.error` through
on the failed branch — the real reason now reaches both the card
and the entry.
…vents

Previously `applyBackgroundTaskTerminalStatus` always tried agent_id
first and then fell back to description match on miss. That fallback
caused two cross-card bugs:

  1. On resume, `applyTerminalBackgroundAgentStatuses` iterates every
     persisted terminal task, including ones whose tool calls fell
     outside the `REPLAY_TURN_LIMIT` window and were never mounted.
     Description fallback could route an old `lost` status onto an
     unrelated recent Agent card sharing the same `args.description`.

  2. During the live spawn → terminate window, the same card briefly
     lives in both `_pendingToolComponents` and `transcriptContainer`.
     A description-only walk visits the same component twice and flags
     itself ambiguous, dropping the otherwise unambiguous update.

When `args.agentId` is provided we now match only by id and skip on
miss. With `getSubagentAgentId` already parsing `agent_id: agent-N`
out of the spawn-success ToolResult, the id path is reliable for
both live and resume even though `tc.subagentAgentId` is never
populated for backgrounded agents. Description fallback is preserved
solely for old pre-PR sessions whose persisted records lack
`agent_id` — same best-effort behavior as before.
@RealKai42
RealKai42 merged commit f3269ea into main May 29, 2026
6 checks passed
@RealKai42
RealKai42 deleted the fix/tui-bg-agent-terminal-status branch May 29, 2026 09:26
@github-actions github-actions Bot mentioned this pull request May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant