Report a backgrounded command into the card that launched it

A backgrounded command has no subagent, so there is no second transcript for
its report to live in and its own tool card is the only record of it anywhere
-- and until the task notification arrives that card is showing the launch
result, which says the command is running. It was left saying that for ever.

The report now updates the call's own row (`Event::ToolUpdate` against its
tool_use id), so the card ends up holding what became of the command instead
of a claim nothing was ever going to correct. That includes the endings that
carry no summary: those are exactly the ones that went wrong, and a stale
"running in background" reads worst on them, so they say the status word
rather than nothing. A task with a subagent behind it is untouched and its
report stays where it was, in that subagent's own transcript.

Echo grew `/background [seconds]` for the shape end to end: the Bash call, the
launch result, a turn that ends `waiting`, and the completion arriving later
to correct the card and start a second turn.

Verified on the emulator: the card reads `Background command "sleep 5 && echo
done" completed (exit code 0)` where it had said "Command running in background
with ID: ...". 172 server tests, ktfmt, clippy, rustfmt, Android lint and the
JVM unit tests clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 21:47:54 -04:00
1 parent 1bbb642973
commit 9cc52beb09
5 files changed
+199 -15

No files matched your search

+101 -4
View File
@@ -631,12 +631,33 @@ impl Translator {
if !self.open_tasks.remove(&about) && !self.subagents.is_open(&about) {
return Vec::new();
}
if let Some(summary) = summary {
self.subagents
.record(&about, Event::AssistantText { delta: summary });
let mut events = Vec::new();
// Where the report goes, and the two cases are not the same place.
//
// A subagent has a transcript of its own, and its closing words are
// that transcript's last line -- see `SUBAGENTS.md`. A backgrounded
// *command* has none: its own tool card is the only record of it
// anywhere, and until this arrives that card is still showing the
// launch result, which says the command is running. It stopped being
// true at this line, so the card is brought up to date rather than
// left making a claim nothing will ever correct.
if self.subagents.get(&about).is_some() {
if let Some(summary) = summary {
self.subagents
.record(&about, Event::AssistantText { delta: summary });
}
} else {
events.push(Event::ToolUpdate {
id: about.clone(),
// A task that stopped without a word still has to say so: the
// states with no summary are exactly the ones that went
// wrong, and they are the ones a stale "running in
// background" reads worst on.
output: summary
.unwrap_or_else(|| format!("this background command {}", status_word(status))),
});
}
self.subagents.finish(&about);
let mut events = Vec::new();
// The last outstanding task, with the session's own turn already
// over: it has stopped being `Waiting` and nothing else will say so.
// Inside a turn there is nothing to announce -- the turn's own
@@ -1037,6 +1058,15 @@ fn closes_a_turn(event: &Event) -> bool {
)
}
/// A task's ending in a word, for a card that has to say what became of it.
///
/// `ended` has already said this is an ending, so the fallback is not "still
/// going" -- it is the honest answer for an ending this build has no word for,
/// which is that nobody here knows which it was.
fn status_word(status: Option<&str>) -> &str {
status.unwrap_or("ended, and this build cannot say how")
}
/// Whether a task status word means the task is over.
///
/// Written as "not one of the words that mean it is still going" rather than
@@ -1488,6 +1518,73 @@ mod tests {
);
}
/// A backgrounded command is a task with no subagent behind it, so there is
/// no second transcript for its report to live in -- its own tool card is
/// the only record of it anywhere, and until the notification arrives that
/// card is still showing the launch result saying it is running.
#[test]
fn a_backgrounded_command_reports_into_the_card_that_launched_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_started","task_id":"bg1","tool_use_id":"toolu_sh","is_backgrounded":true}"#,
],
);
assert_eq!(
translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_notification","task_id":"bg1","tool_use_id":"toolu_sh","status":"completed","summary":"Background command \"run the tests\" completed (exit code 0)"}"#,
],
),
vec![
Event::ToolUpdate {
id: "toolu_sh".into(),
output: r#"Background command "run the tests" completed (exit code 0)"#.into(),
},
Event::Status {
state: SessionStatus::Idle
},
]
);
assert!(
subagents.get("toolu_sh").is_none(),
"a command is not a subagent and must not become one"
);
}
/// The states with no summary are exactly the ones that went wrong, and a
/// card left saying "running in background" is the worst thing to leave on
/// screen for them.
#[test]
fn a_backgrounded_command_that_failed_says_so_rather_than_saying_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), subagents);
translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_started","task_id":"bg2","tool_use_id":"toolu_sh2","is_backgrounded":true}"#,
],
);
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_updated","task_id":"bg2","patch":{"status":"failed"}}"#,
],
);
assert_eq!(
events.first(),
Some(&Event::ToolUpdate {
id: "toolu_sh2".into(),
output: "this background command failed".into(),
})
);
}
/// The case a backend restart produces, which is every subagent a session
/// has when the server is updated under it. Adoption picks the session's
/// stdout back up from a recorded offset, so the `task_started` lines for