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

+70
View File
@@ -57,6 +57,10 @@
//! parent then runs a turn answering it -- which is the whole of the shape a
//! real background Task produces, and the one where two replies used to be
//! drawn as one paragraph.
//! - `/background [seconds]` -- a backgrounded *command*: the same shape with
//! no subagent behind it, so its report has nowhere to go but the card that
//! launched it. Until then that card says the command is running, which is
//! the stale claim this exists to watch being corrected.
//!
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! states that only exist mid-turn, and the obvious way to get one -- ask a
@@ -461,6 +465,72 @@ impl EchoDriver {
return;
}
// A backgrounded command: the task shape with no subagent behind it.
// Its own verb because it is the *other* half of what a task
// notification does -- a subagent's report goes into the subagent's
// transcript, and this one has nowhere to go but the card that
// launched it, which until then is still saying the command is
// running.
if let Some(rest) = text.strip_prefix("/background") {
let seconds = rest.trim().parse::<u64>().unwrap_or(4).clamp(1, 120);
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
self.emit(Event::Status {
state: SessionStatus::Running,
});
let id = format!("echo-background-{}", super::random_hex());
let command = format!("sleep {seconds} && echo done");
self.emit(Event::ToolStart {
id: id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({
"command": command,
"description": "wait a moment",
"run_in_background": true,
}),
});
self.emit(Event::ToolEnd {
id: id.clone(),
output: format!("Command running in background with ID: {id}"),
});
for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') {
self.emit(Event::AssistantText {
delta: word.to_string(),
});
}
// Not idle: the command is still going, and the session will speak
// again with nobody having typed anything.
self.emit(Event::Status {
state: SessionStatus::Waiting,
});
let sink = self.sink.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(seconds)).await;
let _ = sink.send(Event::ToolUpdate {
id,
output: format!(r#"Background command "{command}" completed (exit code 0)"#),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
for word in "Done -- it finished cleanly.".split_inclusive(' ') {
let _ = sink.send(Event::AssistantText {
delta: word.to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
}
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
});
return;
}
// The same word the real CLI takes, so a phone drives both the same way.
// `Driver::compact` is what the manager's route calls; this is the typed
// path onto it.