End a subagent on its own end_turn, not the parent's tool_result, and give the expander a touch-sized row

The Agent tool runs subagents in the background, so the parent's result
arrives at launch while the subagent works on for minutes; finishing on it
read a running agent as finished with a transcript cut off at launch. A
subagent now ends on its own message_delta end_turn, and a later line for a
finished one reopens it, since a background agent can be messaged again.

The card's expander row was only the chevron's height, so a tap for it
landed on the first subcard; it is the platform's 48dp minimum now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 14:44:00 -04:00
1 parent 9fa09b0af1
commit cf10b17c5b
4 files changed
+227 -26

No files matched your search

+25 -7
View File
@@ -50,17 +50,35 @@ There is no separate delete.
2. Every child line is translated by that subagent's own `Translator` 2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by (one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave). content-block index, and parallel subagents interleave).
3. When the parent's `tool_result` for the Task id arrives, the parent gets 3. **The parent's `tool_result` never finishes a subagent.** The Task tool
its `ToolEnd` as before, and the subagent gets `Status Exited`. runs in the background by default: the `tool_result` -- "Async agent
4. When the parent session's process exits (`Status Exited` on the launched..." -- arrives the moment it *starts*, while the subagent goes
on working for however long its own turn takes, sometimes minutes. What
ends it is its own turn ending: the raw API's `message_delta` on its
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
is the model about to call one, not an end), or a `result` line for its
own turn if a future CLI version ever sends one. Either maps to
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
equivalent event `dispatch` produces for an ordinary session is dropped
rather than written. A shipped version of this finished on the
`tool_result` instead, which read a running background agent as
"finished" with its transcript truncated at the moment it launched.
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
exactly what a further line for it means. Same transcript, same child
`Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's. process was the parent's.
A subagent that was mid-flight when the backend restarted keeps working: A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and the registry reopens the existing transcript on the next child line, and
the file continues its sequence. If its Task call finished while the backend the file continues its sequence -- the same reopening #4 describes, whether
was down nothing ever closes it -- its last status stays `Running`, which what closed it was a restart or its own `end_turn`. If its turn ended while
the list reports as **unknown** rather than as running (see the wire shape). the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then.
Title: the Task call's `description` input, then ` (<subagent_type>)` when Title: the Task call's `description` input, then ` (<subagent_type>)` when
one is given; falling back to the tool's name when the child arrives before one is given; falling back to the tool's name when the child arrives before
@@ -71,7 +89,7 @@ one is given; falling back to the tool's name when the child arrives before
- `session/subagent.rs` -- the registry: `Subagents` (per session, in - `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a `Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title, `broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `finish_all()`, `list()` from disk. Drivers get an prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it. `Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds - `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls' one child `Translator` per subagent, remembers pending Task calls'
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -396,10 +397,15 @@ private fun SessionCard(
// already on screen -- see UI_RULES on a control not displacing the text beside it. // already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) { if (session.subagents > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents) .clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics { .semantics {
contentDescription = contentDescription =
+172 -12
View File
@@ -151,10 +151,12 @@ impl Translator {
fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> { fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> {
match self.subagents.get(id) { match self.subagents.get(id) {
Some(subagent) if !subagent.is_open() => { Some(subagent) if !subagent.is_open() => {
// The Task call already ended (or this line is stale from a // Not stale: the Task tool runs in the background by
// resumed conversation) -- see `SUBAGENTS.md`'s lifecycle. // default, so a finished subagent can still be sent another
tracing::debug!("dropping a line for subagent {id}, which has already finished"); // message later (SendMessage) and start working again. A
return Vec::new(); // line arriving after `finish` means exactly that, not a
// conversation that is over -- see `SUBAGENTS.md`.
self.subagents.reopen(id);
} }
Some(_) => {} Some(_) => {}
None => { None => {
@@ -178,7 +180,28 @@ impl Translator {
.clone(); .clone();
let events = child.lock().unwrap().dispatch(message); let events = child.lock().unwrap().dispatch(message);
for event in events { for event in events {
self.subagents.record(id, event); // The subagent's own vocabulary is Running/Exited/Unknown, never
// Idle -- a background Task is either working or it has ended,
// never merely "between turns" the way a session is. Dropped
// here rather than never produced, so a `result` line's own
// `Idle` (dispatch's ordinary end-of-turn event, for a subagent
// dialect that ever sends one) is caught the same way a
// `message_delta` would be.
if !matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
) {
self.subagents.record(id, event);
}
}
// What actually ends a subagent's turn: not the parent's
// `tool_result`, which for a background Task arrives at launch
// ("Async agent launched...") long before the work is done -- see
// `SUBAGENTS.md`.
if ends_a_turn(message) {
self.subagents.finish(id);
} }
Vec::new() Vec::new()
} }
@@ -678,11 +701,11 @@ impl Translator {
id: about.clone(), id: about.clone(),
output: texts.join("\n"), output: texts.join("\n"),
}); });
// A no-op unless `about` is a subagent's own id -- see // Deliberately does *not* finish a subagent `about` might name:
// `SUBAGENTS.md`'s lifecycle #3: the parent gets this `ToolEnd` // the Task tool runs in the background by default, so this
// like any other tool result, and the subagent it names (if it // `tool_result` -- "Async agent launched..." -- arrives at
// names one) gets its `Status::Exited`. // launch, long before the subagent's own work is done. What
self.subagents.finish(&about); // ends it is its own turn ending, handled in `translate_child`.
} }
events events
} }
@@ -705,6 +728,32 @@ fn fallback_title(message: &Value) -> String {
.to_string() .to_string()
} }
/// Whether this line is a subagent's *own* turn ending -- the only thing
/// that does, per `SUBAGENTS.md`: not the parent's `tool_result`, which for
/// a background Task arrives at launch rather than at completion.
///
/// Checked on the raw line rather than on what `dispatch` returns, so this
/// never has to touch the shared `translate_stream_event`/`dispatch` code a
/// top-level session's own turn-ending also goes through -- a subagent's
/// idea of "ended" must not change when a real session's does.
///
/// `message_delta` is the raw API's own signal, carrying the stop reason:
/// `end_turn` is genuinely done, `tool_use` means the model is about to call
/// one and there is more coming. A `result` line is the CLI's own shape for
/// a top-level turn; a subagent has not been observed to send one, but
/// SUBAGENTS.md counts it too in case a future CLI version does.
fn ends_a_turn(message: &Value) -> bool {
match message.get("type").and_then(Value::as_str) {
Some("stream_event") => {
let event = &message["event"];
event.get("type").and_then(Value::as_str) == Some("message_delta")
&& event["delta"].get("stop_reason").and_then(Value::as_str) == Some("end_turn")
}
Some("result") => true,
_ => false,
}
}
/// Whether a failed turn failed because the account is out of quota, and when /// Whether a failed turn failed because the account is out of quota, and when
/// the CLI said the limit lifts. /// the CLI said the limit lifts.
/// ///
@@ -1043,7 +1092,13 @@ mod tests {
/// The parent's `tool_result` for the Task id is what ends the /// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does. /// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test] #[test]
fn the_parents_tool_result_finishes_the_subagent() { fn the_parents_tool_result_does_not_finish_the_subagent() {
// The Task tool runs in the background by default: this
// `tool_result` is "Async agent launched...", arriving the moment
// the subagent *starts*, while it goes on working for however long
// its own turn takes. Finishing it here was the bug -- a running
// background agent read as "finished" with its transcript truncated
// at launch.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir); let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents)); let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
@@ -1058,10 +1113,115 @@ mod tests {
translate_lines( translate_lines(
&mut translator, &mut translator,
&[ &[
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task2","content":"done","is_error":false}]},"parent_tool_use_id":null}"#, r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task2","content":"Async agent launched","is_error":false}]},"parent_tool_use_id":null}"#,
], ],
); );
assert!(subagent.is_open());
}
/// What actually ends a subagent: the raw API's own `message_delta`
/// saying its turn stopped with `end_turn`. Never written into the
/// subagent's own transcript as `Idle` -- its vocabulary has no such
/// state.
#[test]
fn the_subagents_own_end_turn_finishes_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":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task3","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}},"parent_tool_use_id":"toolu_task3"}"#,
],
);
let subagent = subagents.get("toolu_task3").expect("subagent started");
assert!(!subagent.is_open()); assert!(!subagent.is_open());
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(
!lines
.iter()
.any(|entry| matches!(&entry.event, Event::Status { state } if *state == SessionStatus::Idle)),
"a subagent's transcript must never carry Idle: {lines:?}"
);
assert_eq!(
lines.last().unwrap().event,
Event::Status {
state: SessionStatus::Exited
}
);
}
/// `stop_reason: "tool_use"` is the model about to call a tool, with
/// more of the turn still coming -- not an end.
#[test]
fn a_stop_reason_of_tool_use_does_not_finish_the_subagent() {
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":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task4","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use"}},"parent_tool_use_id":"toolu_task4"}"#,
],
);
assert!(
subagents
.get("toolu_task4")
.expect("subagent started")
.is_open()
);
}
/// A background Task can be sent another message long after its first
/// turn ended -- a further child line for it reopens rather than being
/// dropped, and the same transcript and child translator carry on.
#[test]
fn a_line_after_finish_reopens_the_subagent_rather_than_being_dropped() {
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":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task5","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}},"parent_tool_use_id":"toolu_task5"}"#,
],
);
let subagent = subagents.get("toolu_task5").expect("subagent started");
assert!(!subagent.is_open());
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_more","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task5"}"#,
],
);
assert!(subagent.is_open());
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
// Running, [prompt], Exited, Running (reopened), then the new line's
// own ToolStart -- the same transcript throughout, not a new one.
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
assert_eq!(
lines
.iter()
.filter(|entry| matches!(
&entry.event,
Event::Status {
state: SessionStatus::Running
}
))
.count(),
2,
"expected one Running at creation and one at the reopen: {lines:?}"
);
} }
/// Two subagents running at once keep two separate transcripts: tool ids /// Two subagents running at once keep two separate transcripts: tool ids
+24 -7
View File
@@ -89,9 +89,9 @@ impl Subagent {
} }
/// Whether this subagent's last recorded status is not `Exited` -- /// Whether this subagent's last recorded status is not `Exited` --
/// what decides whether a further child line still belongs in its /// what decides whether a further child line reopens it (see
/// transcript. See `SUBAGENTS.md`'s lifecycle: "a child line whose /// `Subagents::reopen`) rather than continuing straight through. See
/// subagent finished already... is ignored". /// `SUBAGENTS.md`'s lifecycle.
pub fn is_open(&self) -> bool { pub fn is_open(&self) -> bool {
*self.status.lock().unwrap() != SessionStatus::Exited *self.status.lock().unwrap() != SessionStatus::Exited
} }
@@ -269,10 +269,12 @@ impl Subagents {
} }
} }
/// The parent's `tool_result` for this Task id arrived: the subagent's /// The subagent's own turn ended: its `Status::Exited`. Called from
/// own `Status::Exited`. A no-op for an id that is not a subagent's, so /// `translate_child` on the subagent's own `end_turn`, never on the
/// callers can call this for every `tool_result` without first checking /// parent's `tool_result` -- a background Task's `tool_result` arrives
/// whether it belongs to one. /// at launch, not at completion, so it says nothing about whether this
/// is over. A no-op for an id that is not a subagent's or is already
/// closed.
pub fn finish(&self, id: &str) { pub fn finish(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned() if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& subagent.is_open() && subagent.is_open()
@@ -283,6 +285,21 @@ impl Subagents {
} }
} }
/// A line arrived for a subagent that had already finished: it is
/// working again, not stale -- a background Task can be sent another
/// message long after its first turn ended. Appends `Status::Running`
/// so the list stops reporting it as finished; a no-op if it was not
/// actually closed, so a caller need not check first.
pub fn reopen(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& !subagent.is_open()
{
subagent.append(Event::Status {
state: SessionStatus::Running,
});
}
}
/// The parent session's process is gone, so nothing still open here has /// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4. /// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
pub fn finish_all(&self) { pub fn finish_all(&self) {