Files
ai-app/server/src/session/subagent.rs
T
iris-aiandClaude Opus 5 942edd6b31 List a session's background tasks above its subagents
The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.

Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.

Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.

The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.

Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 23:29:40 -04:00

722 lines
29 KiB
Rust

//! A session's subagents -- see `SUBAGENTS.md`.
//!
//! **A subagent is a second transcript owned by a session, in the same event
//! model, with no process and no controls.** It shares the transcript file
//! format, the paging routes, and the SSE stream with a session by
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
//! work on a subagent's file unchanged.
//!
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
//! where `<id>` is Claude's Task tool_use id or Codex's child thread id --
//! unique, stable across a backend restart, and already the key the parent
//! side uses. Only ids matching [`is_subagent_id`] are ever turned into a
//! path.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use super::driver::{Event, SessionStatus};
use super::transcript::{SeqEvent, Transcript};
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
/// session's: a subagent's whole conversation is usually a handful of tool
/// calls, not an hours-long session.
const EVENT_BUFFER: usize = 64;
/// Whether `id` is safe to become a path segment under a session's
/// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the
/// id arrives as a value inside JSON the CLI sent, and it becomes a
/// directory name, so a `/` or `..` in it must never be trusted.
fn is_subagent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 200
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
/// What a subagent's directory holds beside its transcript. Small and
/// separate from `Subagent` itself because this is exactly what survives a
/// backend restart on disk, and nothing else does.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Meta {
title: String,
/// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately:
/// `list` sorts by this rather than by directory order, which a
/// filesystem does not promise.
created: f64,
}
/// One row of `GET /sessions/{id}/subagents`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubagentInfo {
pub id: String,
pub title: String,
pub status: SessionStatus,
pub created: f64,
pub last_activity: f64,
}
/// One subagent: its own transcript and broadcast, same shape as a
/// session's but with no driver behind it.
pub struct Subagent {
dir: PathBuf,
/// Its own name, from `meta.json`, so an open subagent can be listed
/// without reading every subagent's directory back off disk.
title: String,
transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than
/// read back from `Transcript::last_status` -- that answers "as of
/// opening" (see its own doc comment) and never moves for an append made
/// through *this* object, which is every append a live subagent ever
/// makes. Without this, `finish` immediately after `start` in the same
/// process read the file's stale opening status and reported itself
/// still open.
status: Mutex<SessionStatus>,
}
impl Subagent {
fn title(&self) -> &str {
&self.title
}
pub fn transcript_path(&self) -> PathBuf {
self.dir.join("transcript.jsonl")
}
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
self.events.subscribe()
}
/// Whether this subagent's last recorded status is not `Exited` --
/// what decides whether a further child line reopens it (see
/// `Subagents::reopen`) rather than continuing straight through. See
/// `SUBAGENTS.md`'s lifecycle.
pub fn is_open(&self) -> bool {
*self.status.lock().unwrap() != SessionStatus::Exited
}
fn append(&self, event: Event) -> bool {
let mut transcript = self.transcript.lock().unwrap();
match transcript.append(event, super::now()) {
Ok(entry) => {
if let Event::Status { state } = &entry.event {
*self.status.lock().unwrap() = *state;
}
// No subscribers is fine; the transcript already has it,
// same as a session's pump.
let _ = self.events.send(entry);
true
}
Err(err) => {
tracing::error!("subagent transcript append failed: {err:#}");
false
}
}
}
}
/// Every subagent one session has started, keyed by the provider's stable
/// parent-side id for it.
///
/// Lives beside a session's driver rather than inside it: a claude driver
/// holds an `Arc` to this and routes child lines into it; echo uses it for
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
/// call. One instance per live session, built at launch and handed to
/// whichever driver replaces it across a stop/start.
pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>,
/// Open subagents by id, with the title each is known by, seeded from disk so an adopted
/// session starts with the measured set rather than waiting to see lifecycle edges which are
/// already behind its stdout cursor. The title is held here so that listing what is open
/// costs no directory read -- `GET /sessions/{id}/background` asks often.
open: Mutex<HashMap<String, String>>,
}
impl Subagents {
pub fn new(session_dir: PathBuf) -> Self {
let subagents = Self {
dir: session_dir,
live: Mutex::new(HashMap::new()),
open: Mutex::new(HashMap::new()),
};
subagents.open.lock().unwrap().extend(
subagents
.list(true)
.into_iter()
.filter(|info| info.status == SessionStatus::Running)
.map(|info| (info.id, info.title)),
);
subagents
}
fn subagents_dir(&self) -> PathBuf {
self.dir.join("subagents")
}
/// Opens the subagent named `id`, creating it if this is the first
/// anyone has heard of it -- on disk as well as in memory, so a
/// subagent from before a backend restart is reopened rather than
/// recreated. `title`/`prompt` are used only at creation: reopening an
/// existing one keeps its original title and never repeats the prompt
/// into its transcript a second time.
fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result<Arc<Subagent>> {
let dir = self.subagents_dir().join(id);
let meta_path = dir.join("meta.json");
let existed = meta_path.is_file();
let meta = if existed {
let text = fs::read_to_string(&meta_path)
.with_context(|| format!("read {}", meta_path.display()))?;
serde_json::from_str::<Meta>(&text).context("parse subagent meta")?
} else {
wg_app_link::private::create_dir(&dir)?;
let meta = Meta {
title: title.to_string(),
created: super::now(),
};
wg_app_link::private::write_file(
&meta_path,
serde_json::to_string(&meta)
.context("serialize subagent meta")?
.as_bytes(),
)?;
meta
};
let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?;
if !existed {
// First lines, in order: the subagent is running the moment it
// exists, and its prompt -- when known -- is genuinely its first
// user turn. Written once, here, so a reopen never repeats them.
transcript.append(
Event::Status {
state: SessionStatus::Running,
},
meta.created,
)?;
if let Some(prompt) = prompt {
transcript.append(
Event::UserMessage {
id: None,
text: prompt.to_string(),
attachments: Vec::new(),
},
meta.created,
)?;
}
}
// A freshly created subagent is running by construction (its only
// lines so far are `Status::Running` and maybe its prompt); a
// reopened one takes whatever the file last said, since this
// `Transcript` has not been appended to yet in this process.
let status = if existed {
transcript.last_status().unwrap_or(SessionStatus::Running)
} else {
SessionStatus::Running
};
let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent {
dir,
title: meta.title,
transcript: Mutex::new(transcript),
events,
status: Mutex::new(status),
}))
}
/// Starts a subagent unless one is already known by this id -- see
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
/// child line, whichever comes first, and never twice. A bad id is
/// refused rather than turned into a path.
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
if !is_subagent_id(id) {
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
return;
}
let mut live = self.live.lock().unwrap();
if live.contains_key(id) {
return;
}
match self.open_or_create(id, title, prompt) {
Ok(subagent) => {
if subagent.is_open() {
self.open
.lock()
.unwrap()
.insert(id.to_string(), subagent.title().to_string());
}
live.insert(id.to_string(), subagent);
}
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
}
}
/// The subagent named `id`, reopening it from disk on first use in this
/// process if one is there. `None` for an id nothing has ever started --
/// deliberately not created here, since a route or a routing decision is
/// not the Task call that is supposed to be the only way one begins.
pub fn get(&self, id: &str) -> Option<Arc<Subagent>> {
if !is_subagent_id(id) {
return None;
}
if let Some(existing) = self.live.lock().unwrap().get(id).cloned() {
return Some(existing);
}
if !self.subagents_dir().join(id).join("meta.json").is_file() {
return None;
}
// Title and prompt are ignored: the directory already exists, so
// `open_or_create` reads its own meta rather than using either.
match self.open_or_create(id, "", None) {
Ok(subagent) => {
self.live
.lock()
.unwrap()
.insert(id.to_string(), Arc::clone(&subagent));
Some(subagent)
}
Err(err) => {
tracing::error!("couldn't reopen subagent {id}: {err:#}");
None
}
}
}
/// Whether the subagent named `id` exists and has not finished. `false`
/// for an id that is not a subagent's at all -- a backgrounded command's
/// tool call reaches here with the same shape.
pub fn is_open(&self, id: &str) -> bool {
self.get(id).is_some_and(|subagent| subagent.is_open())
}
/// Whether this session has any subagent still working.
///
/// That is the whole point of it. A backend restart adopts a session's
/// process and picks its stdout back up from a recorded offset, so the
/// `task_started` lines for subagents launched before the restart are
/// already behind that offset and the translator never sees them -- it
/// starts with an empty set and reports the session `Idle` at the end of
/// a turn it should have called [`SessionStatus::Waiting`].
pub fn any_open(&self, session_running: bool) -> bool {
session_running && self.open_count() > 0
}
/// Latest measured number of live subagents, including ones found on disk at construction.
pub fn open_count(&self) -> usize {
self.open.lock().unwrap().len()
}
/// The live subagents, each with the title it is known by. Ordered by
/// id, which says nothing about when they started but does mean two
/// readings agree; read from memory, so a caller may ask often.
pub fn open_list(&self) -> Vec<(String, String)> {
let mut open: Vec<(String, String)> = self
.open
.lock()
.unwrap()
.iter()
.map(|(id, title)| (id.clone(), title.clone()))
.collect();
open.sort();
open
}
/// Appends one event to a subagent's own transcript. A no-op, with a
/// debug log, for an id nothing was started under -- a child line for a
/// subagent this registry never opened is dropped rather than guessed
/// at.
pub fn record(&self, id: &str, event: Event) {
match self.live.lock().unwrap().get(id).cloned() {
Some(subagent) => {
subagent.append(event);
}
None => tracing::debug!("dropping an event for unknown subagent {id}"),
}
}
/// The subagent's own turn ended: its `Status::Exited`. Called from
/// `translate_child` on the subagent's own `end_turn`, never on the
/// parent's `tool_result` -- a background Task's `tool_result` arrives
/// 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) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& subagent.is_open()
&& subagent.append(Event::Status {
state: SessionStatus::Exited,
})
{
self.open.lock().unwrap().remove(id);
}
}
/// 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,
})
{
self.open
.lock()
.unwrap()
.insert(id.to_string(), subagent.title().to_string());
}
}
/// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #5.
///
/// Read from the directory rather than from `live`, because a subagent
/// left `Running` by a *previous* run of this server is exactly the one
/// that needs closing and is the one `live` does not have: nothing in
/// this process ever touched it, so it would keep reading `running`
/// every time its session was started again, with nothing able to
/// correct it.
pub fn finish_all(&self) {
// `list(true)` reports each one's own last status rather than
// rewriting a running one as unknown -- what is wanted here is which
// are open on disk, and this call is the very thing that decides the
// session is not running.
for info in self.list(true) {
if info.status != SessionStatus::Running {
continue;
}
let _ = self.get(&info.id);
self.finish(&info.id);
}
}
/// Removes subagents, transcripts and all -- `POST
/// /sessions/{id}/subagents/delete`, and the path out for [`start`]
/// short of deleting the whole session.
///
/// **All or nothing, and only for one that has finished.** Every id is
/// checked before anything is removed, so a batch naming one that is
/// still running leaves the others exactly as they were rather than
/// deleting up to the offender -- the reader picked a set, and a set
/// half-deleted is indistinguishable, on the list, from rows they never
/// picked. Refusing a running one is not withholding the capability:
/// its transcript is still being written to, and its process is the
/// session's to stop.
///
/// `session_running` decides what "running" means here, exactly as it
/// does in [`Subagents::list`].
///
/// [`start`]: Subagents::start
pub fn delete(&self, ids: &[String], session_running: bool) -> Result<()> {
let dirs: Vec<PathBuf> = ids
.iter()
.map(|id| {
anyhow::ensure!(is_subagent_id(id), "{id} is not a subagent id");
Ok(self.subagents_dir().join(id))
})
.collect::<Result<_>>()?;
for (id, dir) in ids.iter().zip(&dirs) {
let info = info_of(dir, session_running)
.with_context(|| format!("there is no subagent {id} here"))?;
anyhow::ensure!(
info.status != SessionStatus::Running,
"\"{}\" is still running -- it can be deleted once it has finished",
info.title
);
}
let mut live = self.live.lock().unwrap();
for (id, dir) in ids.iter().zip(&dirs) {
fs::remove_dir_all(dir).with_context(|| format!("delete {}", dir.display()))?;
// Out of the registry as well as off the disk, so a later child
// line for this id starts a new subagent rather than appending
// to an unlinked file nothing can read.
live.remove(id);
self.open.lock().unwrap().remove(id);
}
Ok(())
}
/// Every subagent under this session's directory, oldest first --
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
/// from `live`, so a subagent from before this process started (or one
/// this run has not yet touched) still shows up; one file read per
/// subagent, which is fine at the handful a session usually has.
///
/// `session_running` is what turns a subagent whose last status is
/// `Running` into `Unknown`: its process was the session's, and the
/// session has none.
pub fn list(&self, session_running: bool) -> Vec<SubagentInfo> {
let mut rows: Vec<SubagentInfo> = match fs::read_dir(self.subagents_dir()) {
Ok(entries) => entries
.filter_map(Result::ok)
.filter_map(|entry| info_of(&entry.path(), session_running))
.collect(),
// No directory is no subagents, not a fault worth reporting.
Err(_) => Vec::new(),
};
rows.sort_by(|a, b| {
a.created
.partial_cmp(&b.created)
.unwrap_or(std::cmp::Ordering::Equal)
});
rows
}
}
fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
let id = subagent_dir.file_name()?.to_str()?.to_string();
let meta_path = subagent_dir.join("meta.json");
let text = fs::read_to_string(&meta_path).ok()?;
let meta: Meta = serde_json::from_str(&text).ok()?;
let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?;
// The subagent's first line is always `Status::Running`, written before
// this directory is discoverable at all, so `None` here is not a state a
// reader can actually observe -- but it is not this function's place to
// invent one, so a status this build does not expect to see falls back
// to the word the lifecycle promises it started in.
let last_status = transcript.last_status().unwrap_or(SessionStatus::Running);
let status = if last_status == SessionStatus::Running && !session_running {
SessionStatus::Unknown
} else {
last_status
};
Some(SubagentInfo {
id,
title: meta.title,
status,
created: meta.created,
last_activity: transcript.last_activity().unwrap_or(meta.created),
})
}
/// How many subagents a session has, for `SessionInfo::subagents`: a
/// directory listing, so the session list stays cheap and only the
/// dedicated route pays for reading a status out of each one.
pub fn count(session_dir: &Path) -> usize {
fs::read_dir(session_dir.join("subagents"))
.map(|entries| entries.filter_map(Result::ok).count())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bad_id_is_refused_rather_than_turned_into_a_path() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("../../etc", "escape", None);
assert!(subagents.get("../../etc").is_none());
assert!(!dir.path().join("subagents").exists());
}
#[test]
fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_1", "first title", Some("do the thing"));
subagents.start("toolu_1", "second title", Some("do the thing"));
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "first title");
let events = crate::session::transcript::read_after(
&subagents.get("toolu_1").unwrap().transcript_path(),
0,
)
.expect("read");
assert_eq!(
events
.iter()
.filter(|e| matches!(e.event, Event::UserMessage { .. }))
.count(),
1
);
assert_eq!(subagents.open_count(), 1);
}
#[test]
fn a_reopened_subagent_continues_its_own_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_2", "helper", Some("go"));
subagents.record(
"toolu_2",
Event::AssistantText {
delta: "working".to_string(),
},
);
}
// A fresh registry, the way a backend restart builds one.
let subagents = Subagents::new(dir.path().to_path_buf());
assert_eq!(subagents.open_count(), 1);
let subagent = subagents.get("toolu_2").expect("reopened");
assert!(subagent.is_open());
subagents.record(
"toolu_2",
Event::AssistantText {
delta: " more".to_string(),
},
);
let events =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
// Status, UserMessage, two AssistantText deltas, seq continuing.
assert_eq!(events.len(), 4);
assert_eq!(events.last().unwrap().seq, 4);
}
#[test]
fn finishing_appends_exited_and_further_lines_are_droppable() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_3", "helper", None);
subagents.finish("toolu_3");
assert_eq!(subagents.open_count(), 0);
let subagent = subagents.get("toolu_3").unwrap();
assert!(!subagent.is_open());
// On disk too, not only in the live cache `is_open` reads.
assert_eq!(
Transcript::open(&subagent.transcript_path())
.expect("reopen")
.last_status(),
Some(SessionStatus::Exited)
);
// Finishing an id that was never a subagent is a no-op, not a panic.
subagents.finish("never-started");
subagents.reopen("toolu_3");
assert_eq!(subagents.open_count(), 1);
subagents.finish("toolu_3");
assert_eq!(subagents.open_count(), 0);
}
#[test]
fn finish_all_closes_one_left_running_by_an_earlier_run() {
// The state a backend restart leaves behind: the subagent is on disk
// reading `Running` and nothing in this process has touched it, so a
// registry that only knew its own `live` map left it running for
// ever -- and the phone showed it as running every time the session
// was started again.
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_stale", "helper", None);
}
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.finish_all();
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].status, SessionStatus::Exited);
}
#[test]
fn deleting_a_finished_subagent_takes_its_directory_with_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "helper", None);
subagents.finish("toolu_done");
subagents
.delete(&["toolu_done".to_string()], true)
.expect("delete");
assert!(subagents.list(true).is_empty());
assert!(!dir.path().join("subagents").join("toolu_done").exists());
// Out of the live registry too, so a later line starts a new one rather than appending to
// a file nothing can read.
assert!(subagents.get("toolu_done").is_none());
}
#[test]
fn deleting_a_batch_with_a_running_one_in_it_deletes_none_of_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_done", "finished helper", None);
subagents.finish("toolu_done");
subagents.start("toolu_busy", "busy helper", None);
let err = subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], true)
.expect_err("refused");
assert!(err.to_string().contains("busy helper"), "{err:#}");
assert_eq!(subagents.list(true).len(), 2);
// The same batch once the session behind it has no process: nothing there is running, so
// both go.
subagents
.delete(&["toolu_done".to_string(), "toolu_busy".to_string()], false)
.expect("delete");
assert!(subagents.list(false).is_empty());
}
#[test]
fn deleting_an_id_that_is_not_there_is_refused_rather_than_ignored() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert!(
subagents
.delete(&["toolu_ghost".to_string()], true)
.is_err()
);
assert!(subagents.delete(&["../../etc".to_string()], true).is_err());
}
#[test]
fn finish_all_closes_only_what_is_still_open() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_4", "one", None);
subagents.start("toolu_5", "two", None);
subagents.finish("toolu_4");
subagents.finish_all();
let rows = subagents.list(false);
assert_eq!(rows.len(), 2);
for row in rows {
assert_eq!(row.status, SessionStatus::Exited);
}
}
#[test]
fn a_subagent_still_running_when_the_session_is_not_reports_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_6", "helper", None);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown);
}
#[test]
fn list_is_oldest_first_and_the_count_matches_the_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert_eq!(count(dir.path()), 0);
subagents.start("toolu_a", "a", None);
std::thread::sleep(std::time::Duration::from_millis(2));
subagents.start("toolu_b", "b", None);
let rows = subagents.list(true);
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["toolu_a", "toolu_b"]
);
assert_eq!(count(dir.path()), 2);
}
}