Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff5c8b0c0
commit
9fa09b0af1
21 files changed
+1953
-332
No files matched your search
+110
-1
@@ -48,6 +48,10 @@
|
||||
//! and height the app draws, in one session, which is what a scrolling
|
||||
//! problem needs in order to be reproduced twice the same way.
|
||||
//! - `/table [columns]` -- a markdown table with cells too long for one line.
|
||||
//! - `/subagent [n]` -- n subagents at once (default 1), each named
|
||||
//! "helper k", its prompt recorded as its own first user message: a
|
||||
//! streamed reply, one Bash call, then it finishes about three seconds
|
||||
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
|
||||
//!
|
||||
//! `/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
|
||||
@@ -62,6 +66,7 @@ use std::time::Duration;
|
||||
use super::driver::{
|
||||
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
|
||||
};
|
||||
use super::subagent::Subagents;
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming, short enough that tests waiting on a full turn stay fast.
|
||||
@@ -108,6 +113,10 @@ pub struct EchoDriver {
|
||||
/// says it recovered, and a clear leaves it unmeasured. What is real is
|
||||
/// which way the numbers move.
|
||||
context: Arc<AtomicU64>,
|
||||
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
|
||||
/// test rig for the same registry the claude driver routes real Task
|
||||
/// calls into.
|
||||
subagents: Arc<Subagents>,
|
||||
}
|
||||
|
||||
impl EchoDriver {
|
||||
@@ -384,6 +393,55 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// `n` subagents at once, each with its own transcript in the
|
||||
// registry a real Task call routes into -- see `SUBAGENTS.md`. The
|
||||
// parent's own Task calls end when their subagent does, three
|
||||
// seconds later, which is long enough to see the running state on
|
||||
// the phone before it finishes.
|
||||
if let Some(rest) = text.strip_prefix("/subagent") {
|
||||
let n = rest.trim().parse::<usize>().unwrap_or(1).clamp(1, 8);
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
let sink = self.sink.clone();
|
||||
let subagents = Arc::clone(&self.subagents);
|
||||
tokio::spawn(async move {
|
||||
let mut helpers = Vec::new();
|
||||
for k in 1..=n {
|
||||
let id = format!("echo-subagent-{k}-{}", super::random_hex());
|
||||
let title = format!("helper {k}");
|
||||
let prompt = format!(
|
||||
"You are helper {k} of {n}. Say a few words, run a command, then stop."
|
||||
);
|
||||
let _ = sink.send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Task".to_string(),
|
||||
input: serde_json::json!({
|
||||
"description": title,
|
||||
"prompt": prompt,
|
||||
"subagent_type": "general-purpose",
|
||||
}),
|
||||
});
|
||||
subagents.start(&id, &title, Some(&prompt));
|
||||
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
|
||||
}
|
||||
for (id, sink, subagents) in helpers {
|
||||
tokio::spawn(run_helper(id, sink, subagents));
|
||||
}
|
||||
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.
|
||||
@@ -679,7 +737,12 @@ impl EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(sink: EventSink, session_dir: PathBuf, usage: crate::usage::Fixture) -> Self {
|
||||
pub fn new(
|
||||
sink: EventSink,
|
||||
session_dir: PathBuf,
|
||||
usage: crate::usage::Fixture,
|
||||
subagents: Arc<Subagents>,
|
||||
) -> Self {
|
||||
let driver = Self {
|
||||
sink,
|
||||
pending_questions: Mutex::new(Vec::new()),
|
||||
@@ -688,6 +751,7 @@ impl EchoDriver {
|
||||
queued: Arc::new(Mutex::new(Vec::new())),
|
||||
session_dir,
|
||||
usage,
|
||||
subagents,
|
||||
};
|
||||
driver.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
@@ -809,6 +873,51 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
|
||||
/// One `/subagent` helper: a few streamed words, one Bash call, then
|
||||
/// `Status::Exited` about three seconds after it started -- long enough that
|
||||
/// its `Running` state can be seen on the phone before it finishes. The
|
||||
/// parent's own Task call for it ends at the same moment, the same way a
|
||||
/// real Task's `tool_result` ends it.
|
||||
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
||||
let start = tokio::time::Instant::now();
|
||||
for word in "Working on it now.".split_inclusive(' ') {
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
}
|
||||
let tool_id = format!("{id}-bash");
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::ToolStart {
|
||||
id: tool_id.clone(),
|
||||
tool: "Bash".to_string(),
|
||||
input: serde_json::json!({ "command": "echo helper done" }),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::ToolEnd {
|
||||
id: tool_id,
|
||||
output: "helper done".to_string(),
|
||||
},
|
||||
);
|
||||
let target = Duration::from_secs(3);
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed < target {
|
||||
tokio::time::sleep(target - elapsed).await;
|
||||
}
|
||||
subagents.finish(&id);
|
||||
let _ = sink.send(Event::ToolEnd {
|
||||
id,
|
||||
output: "subagent finished".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// A message written during a turn and waiting for it to end: the id of the
|
||||
/// `MessageQueued` that announced it, what it said, and what was attached. All
|
||||
/// three, because all three are what the `MessageTaken` at the other end owes.
|
||||
|
||||
Reference in new issue
Block a user