sessions : registre central + am sessions (issue #5) #20

Merged
bruno merged 1 commits from feat/sessions into main 2026-08-16 22:22:23 -04:00
10 changed files with 631 additions and 3 deletions
+14
View File
@@ -98,6 +98,20 @@ pub enum Command {
/// Agent name or alias (omit to show all installed agents) /// Agent name or alias (omit to show all installed agents)
agent: Option<String>, agent: Option<String>,
}, },
/// List the sessions of every agent (and of the interactive shell)
Sessions {
/// Only sessions of this agent
agent: Option<String>,
/// Only sessions of this project
#[arg(long, value_name = "PROJECT")]
project: Option<String>,
/// Only sessions with this status (running, stopped, failed, interrupted)
#[arg(long, value_name = "STATUS")]
status: Option<String>,
/// Show one session in detail (summary + log excerpt)
#[arg(long, value_name = "ID")]
show: Option<String>,
},
/// Install an agent and its dependencies /// Install an agent and its dependencies
Install { Install {
/// Agent name or alias /// Agent name or alias
+13
View File
@@ -11,6 +11,7 @@ pub mod list_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod self_uninstall; pub mod self_uninstall;
pub mod sessions_cmd;
pub mod self_update; pub mod self_update;
pub mod status_cmd; pub mod status_cmd;
pub mod uninstall_cmd; pub mod uninstall_cmd;
@@ -48,6 +49,18 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout, timeout,
} => run_cmd::restart(app, start, *force, *timeout), } => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()), Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Sessions {
agent,
project,
status,
show,
} => sessions_cmd::run(
app,
agent.as_deref(),
project.as_deref(),
status.as_deref(),
show.as_deref(),
),
Command::Install { Command::Install {
agent, agent,
method, method,
+12
View File
@@ -4,6 +4,7 @@ use super::*;
use crate::cli::StartArgs; use crate::cli::StartArgs;
use crate::events::{self, Event, EventKind}; use crate::events::{self, Event, EventKind};
use crate::process; use crate::process;
use crate::sessions;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ffi::OsString; use std::ffi::OsString;
@@ -65,6 +66,15 @@ fn start_one(
let _ = app.state.touch(&agent.name); let _ = app.state.touch(&agent.name);
} }
let log = process::agent_log_path(app, &agent.name); let log = process::agent_log_path(app, &agent.name);
let _ = sessions::start_agent(
app,
&agent.name,
pid,
cwd_string(),
exec.args.clone(),
events::env_keys(&exec.env),
Some(log.display().to_string()),
);
app.log.success(&format!( app.log.success(&format!(
"{} started (pid {pid}); log: {}", "{} started (pid {pid}); log: {}",
agent.title(), agent.title(),
@@ -154,6 +164,7 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
.with_pid(pid) .with_pid(pid)
.with_reason("stale"), .with_reason("stale"),
); );
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
app.state.update_pid(&agent.name, None, None)?; app.state.update_pid(&agent.name, None, None)?;
return Ok(0); return Ok(0);
} }
@@ -169,6 +180,7 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
ev = ev.with_duration(d); ev = ev.with_duration(d);
} }
app.emit(&ev); app.emit(&ev);
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
app.state.update_pid(&agent.name, None, None)?; app.state.update_pid(&agent.name, None, None)?;
if stopped { if stopped {
app.log.success(&format!("{} stopped", agent.title())); app.log.success(&format!("{} stopped", agent.title()));
+146
View File
@@ -0,0 +1,146 @@
//! sessions: the registry of every agent and REPL session.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::sessions::{self, SessionRecord};
use serde::Serialize;
#[derive(Serialize)]
struct SessionRow {
id: String,
kind: String,
agent: Option<String>,
status: String,
pid: Option<u32>,
cwd: Option<String>,
project: Option<String>,
started_at: String,
ended_at: Option<String>,
duration_s: Option<u64>,
exit_code: Option<i32>,
log: Option<String>,
}
fn row_of(r: &SessionRecord) -> SessionRow {
SessionRow {
id: r.id.clone(),
kind: r.kind.clone(),
agent: r.agent.clone(),
status: r.status.clone(),
pid: r.pid,
cwd: r.cwd.clone(),
project: r.project.clone(),
started_at: r.started_at.clone(),
ended_at: r.ended_at.clone(),
duration_s: sessions::duration_s(r),
exit_code: r.exit_code,
log: r.log.clone(),
}
}
pub fn run(
app: &App,
agent: Option<&str>,
project: Option<&str>,
status: Option<&str>,
show: Option<&str>,
) -> Result<i32> {
if let Some(id) = show {
return show_one(app, id);
}
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let mut rows: Vec<SessionRow> = sf
.sessions
.values()
.filter(|r| agent.map_or(true, |a| r.agent.as_deref() == Some(a)))
.filter(|r| project.map_or(true, |p| r.project.as_deref() == Some(p)))
.filter(|r| status.map_or(true, |s| r.status == s))
.map(row_of)
.collect();
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let theme = app.theme();
let mut table = Table::new(vec!["SESSION", "AGENT", "STATUS", "DURATION", "EXIT"]);
for r in &rows {
table.row(vec![
short_id(&r.id),
r.agent
.clone()
.unwrap_or_else(|| format!("({})", r.kind)),
status_style(&r.status, theme, color),
r.duration_s
.map(format_duration)
.unwrap_or_else(|| "—".to_string()),
r.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log
.info("no sessions recorded yet — 'am start <agent> --background' or run 'am'");
}
Ok(0)
}
fn show_one(app: &App, id: &str) -> Result<i32> {
sessions::reconcile(app)?;
let sf = sessions::load(app)?;
let Some(r) = sf.sessions.get(id).cloned() else {
bail!("unknown session '{id}' — 'am sessions' lists every session");
};
if app.json() {
print_json(&row_of(&r));
return Ok(0);
}
println!("session {}", r.id);
println!(" kind: {}", r.kind);
println!(" agent: {}", r.agent.as_deref().unwrap_or("—"));
println!(" status: {}", r.status);
println!(" pid: {}", r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()));
println!(" cwd: {}", r.cwd.as_deref().unwrap_or("—"));
println!(" project: {}", r.project.as_deref().unwrap_or("—"));
println!(" started: {}", r.started_at);
println!(" ended: {}", r.ended_at.as_deref().unwrap_or("—"));
if let Some(d) = sessions::duration_s(&r) {
println!(" duration: {}", format_duration(d));
}
if let Some(c) = r.exit_code {
println!(" exit code: {c}");
}
if let Some(l) = &r.log {
let p = std::path::PathBuf::from(l);
if p.exists() {
println!(" log: {l}");
let tail = crate::process::last_lines(&p, 8);
if !tail.is_empty() {
println!(" --- log excerpt ---");
println!("{tail}");
}
} else {
println!(" log: {l} (missing)");
}
}
Ok(0)
}
/// "20260815_143926_a1b2c3" -> "20260815_143926"
fn short_id(id: &str) -> String {
id.chars().take(15).collect()
}
fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
+21
View File
@@ -177,6 +177,27 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Show one agent.", code: "status claude-code" }, HelpExample { desc: "Show one agent.", code: "status claude-code" },
], ],
}, },
HelpSpec {
name: "sessions",
category: "Commands",
usage: "sessions {flags} [agent]",
about: "List the sessions of every agent and of the interactive shell.",
search_terms: &["session", "history", "log"],
flags: &[
HelpFlag { short: "", long: "--project", value: "PROJECT", desc: "Only sessions of this project" },
HelpFlag { short: "", long: "--status", value: "STATUS", desc: "Only sessions with this status (running, stopped, failed, interrupted)" },
HelpFlag { short: "", long: "--show", value: "ID", desc: "Show one session in detail (summary + log excerpt)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Only sessions of this agent" },
],
io: None,
examples: &[
HelpExample { desc: "List recent sessions.", code: "sessions" },
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
],
},
HelpSpec { HelpSpec {
name: "search", name: "search",
category: "Commands", category: "Commands",
+1
View File
@@ -29,6 +29,7 @@ pub mod ps;
pub mod process; pub mod process;
pub mod repl; pub mod repl;
pub mod runner; pub mod runner;
pub mod sessions;
pub mod shell; pub mod shell;
pub mod state; pub mod state;
pub mod tables; pub mod tables;
+7 -3
View File
@@ -60,6 +60,7 @@ pub struct AmCompleter {
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"), ("list", "list installed agents"),
("status", "show agent state"), ("status", "show agent state"),
("sessions", "list agent sessions"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -136,7 +137,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update", "list", "status", "sessions", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -890,8 +891,9 @@ pub fn run(app: &App) -> Result<i32> {
.with_session(sid.clone()) .with_session(sid.clone())
.with_cwd(Some(current_dir_string())), .with_cwd(Some(current_dir_string())),
); );
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
print!("{}", banner(app, &session, &sid)); print!("{}", banner(app, &session, &sid));
match run_with_editor(app, &mut session) { let result = match run_with_editor(app, &mut session) {
Ok(code) => Ok(code), Ok(code) => Ok(code),
Err(e) => { Err(e) => {
app.log.verbose(&format!( app.log.verbose(&format!(
@@ -899,7 +901,9 @@ pub fn run(app: &App) -> Result<i32> {
)); ));
run_plain(app, &mut session) run_plain(app, &mut session)
} }
} };
let _ = crate::sessions::finish_repl(app, &sid);
result
} }
fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> { fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
+338
View File
@@ -0,0 +1,338 @@
//! Session registry: one record per agent session (background start/stop
//! cycles and interactive REPL sessions). sessions.json is an index derived
//! from the event journal; it can be rebuilt with 'am doctor --fix'.
use crate::app::App;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
pub const SESSIONS_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionRecord {
pub id: String,
/// "agent" or "repl".
#[serde(default = "kind_agent")]
pub kind: String,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub project: Option<String>,
/// RFC 3339 start timestamp.
pub started_at: String,
/// RFC 3339 end timestamp (None while running).
#[serde(default)]
pub ended_at: Option<String>,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub log: Option<String>,
#[serde(default)]
pub args: Vec<String>,
/// Variable names only — never values.
#[serde(default)]
pub env_keys: Vec<String>,
/// running | stopped | failed | interrupted
#[serde(default = "status_running")]
pub status: String,
}
fn kind_agent() -> String {
"agent".to_string()
}
fn status_running() -> String {
"running".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionsFile {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub sessions: BTreeMap<String, SessionRecord>,
}
/// Path of sessions.json (inside the state directory).
pub fn file_path(app: &App) -> PathBuf {
app.events_dir().join("sessions.json")
}
pub fn load(app: &App) -> Result<SessionsFile> {
let p = file_path(app);
if !p.exists() {
return Ok(SessionsFile {
version: SESSIONS_VERSION,
sessions: BTreeMap::new(),
});
}
let text = std::fs::read_to_string(&p)
.with_context(|| format!("cannot read sessions file {}", p.display()))?;
let mut sf: SessionsFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"sessions file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
p.display()
)
})?;
if sf.version < SESSIONS_VERSION {
sf.version = SESSIONS_VERSION;
save(app, &sf)?;
}
Ok(sf)
}
pub fn save(app: &App, sf: &SessionsFile) -> Result<()> {
let p = file_path(app);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = p.with_extension("json.tmp");
let text = serde_json::to_string_pretty(sf)?;
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &p)
.with_context(|| format!("cannot write sessions file {}", p.display()))?;
Ok(())
}
/// Open a new agent session; returns its id.
#[allow(clippy::too_many_arguments)]
pub fn start_agent(
app: &App,
agent: &str,
pid: u32,
cwd: Option<String>,
args: Vec<String>,
env_keys: Vec<String>,
log: Option<String>,
) -> Result<String> {
let mut sf = load(app)?;
let id = crate::repl::session_id();
sf.sessions.insert(
id.clone(),
SessionRecord {
id: id.clone(),
kind: "agent".to_string(),
agent: Some(agent.to_string()),
pid: Some(pid),
cwd,
project: None,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log,
args,
env_keys,
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(id)
}
/// Open a REPL session.
pub fn start_repl(app: &App, id: &str, cwd: Option<String>) -> Result<()> {
let mut sf = load(app)?;
sf.sessions.insert(
id.to_string(),
SessionRecord {
id: id.to_string(),
kind: "repl".to_string(),
agent: None,
pid: None,
cwd,
project: None,
started_at: crate::installers::now_rfc3339(),
ended_at: None,
exit_code: None,
log: None,
args: Vec::new(),
env_keys: Vec::new(),
status: "running".to_string(),
},
);
save(app, &sf)?;
let _ = app.state.bump_sessions();
Ok(())
}
/// Close the running session of an agent: the one matching the pid when
/// given, otherwise the most recent one.
pub fn finish_agent(app: &App, agent: &str, pid: Option<u32>, exit_code: i32, ok: bool) -> Result<()> {
let mut sf = load(app)?;
let mut candidates: Vec<String> = sf
.sessions
.iter()
.filter(|(_, r)| r.kind == "agent" && r.status == "running" && r.agent.as_deref() == Some(agent))
.map(|(k, _)| k.clone())
.collect();
candidates.sort_by(|a, b| sf.sessions[b].started_at.cmp(&sf.sessions[a].started_at));
let key = if let Some(p) = pid {
candidates
.iter()
.find(|k| sf.sessions[*k].pid == Some(p))
.cloned()
.or_else(|| candidates.first().cloned())
} else {
candidates.first().cloned()
};
if let Some(k) = key {
if let Some(r) = sf.sessions.get_mut(&k) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(exit_code);
r.status = if ok { "stopped" } else { "failed" }.to_string();
}
save(app, &sf)?;
}
Ok(())
}
/// Close a REPL session.
pub fn finish_repl(app: &App, id: &str) -> Result<()> {
let mut sf = load(app)?;
if let Some(r) = sf.sessions.get_mut(id) {
r.ended_at = Some(crate::installers::now_rfc3339());
r.exit_code = Some(0);
r.status = "stopped".to_string();
save(app, &sf)?;
}
Ok(())
}
/// Mark running sessions whose process is dead as interrupted. Returns the
/// number of sessions reconciled. REPL sessions (no pid) are left untouched:
/// the current REPL has no pid, and only 'am sessions' triggers this.
pub fn reconcile(app: &App) -> Result<usize> {
let mut sf = load(app)?;
let now = crate::installers::now_rfc3339();
let mut changed = 0;
for r in sf.sessions.values_mut() {
if r.status != "running" {
continue;
}
let dead = match r.pid {
Some(p) => !crate::process::is_running(p),
None => false,
};
if dead {
r.status = "interrupted".to_string();
r.ended_at = Some(now.clone());
changed += 1;
}
}
if changed > 0 {
save(app, &sf)?;
}
Ok(changed)
}
/// Duration of a session in seconds (elapsed when still running).
pub fn duration_s(r: &SessionRecord) -> Option<u64> {
let start = chrono::DateTime::parse_from_rfc3339(&r.started_at).ok()?;
let end = match &r.ended_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e).ok()?,
None => chrono::Utc::now().into(),
};
let secs = end
.with_timezone(&chrono::Utc)
.signed_duration_since(start.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn test_app(tag: &str) -> App {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.yaml");
std::fs::write(
&cfg,
concat!(
"version: \"1.0\"\n",
"settings:\n",
" auto_install_deps: false\n",
" confirm_before_run: false\n",
"agents: []\n",
),
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let mut app = App::from_cli(cli).unwrap();
let mut p = app.paths.clone();
p.install_dir = dir.path().join("agents");
p.bin_dir = dir.path().join("agents").join("bin");
p.log_dir = dir.path().join("logs");
p.state_file = dir.path().join("state.json");
p.probe_cache_file = dir.path().join("probe-cache.json");
p.config_dir = Some(dir.path().join("config"));
app.paths = p;
app.state = crate::state::StateStore::new(dir.path().join("state.json"));
let _ = tag;
app
}
#[test]
fn agent_session_lifecycle() {
let app = test_app("sess");
let id = start_agent(&app, "claude-code", 4242, Some("/tmp".into()), vec![], vec![], None).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.pid, Some(4242));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
assert!(duration_s(r).is_some());
}
#[test]
fn reconcile_marks_dead_pid_interrupted() {
let app = test_app("recon");
let id = start_agent(&app, "aider", u32::MAX - 1, None, vec![], vec![], None).unwrap();
let changed = reconcile(&app).unwrap();
assert_eq!(changed, 1);
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get(&id).unwrap().status, "interrupted");
}
#[test]
fn repl_session_lifecycle() {
let app = test_app("repl");
start_repl(&app, "s1", None).unwrap();
finish_repl(&app, "s1").unwrap();
let sf = load(&app).unwrap();
assert_eq!(sf.sessions.get("s1").unwrap().status, "stopped");
}
#[test]
fn finish_matches_by_pid_first() {
let app = test_app("multi");
start_agent(&app, "jcode", 100, None, vec![], vec![], None).unwrap();
start_agent(&app, "jcode", 200, None, vec![], vec![], None).unwrap();
finish_agent(&app, "jcode", Some(200), 1, false).unwrap();
let sf = load(&app).unwrap();
let stopped: Vec<_> = sf
.sessions
.values()
.filter(|r| r.status == "stopped" || r.status == "failed")
.collect();
assert_eq!(stopped.len(), 1);
assert_eq!(stopped[0].pid, Some(200));
assert_eq!(stopped[0].exit_code, Some(1));
assert_eq!(stopped[0].status, "failed");
}
}
+7
View File
@@ -155,6 +155,13 @@ impl StateStore {
} }
Ok(()) Ok(())
} }
/// Increment the total session counter (one per session start).
pub fn bump_sessions(&self) -> Result<()> {
let mut sf = self.load()?;
sf.sessions_count = sf.sessions_count.saturating_add(1);
self.save(&sf)
}
} }
#[cfg(test)] #[cfg(test)]
+72
View File
@@ -0,0 +1,72 @@
//! sessions command and session registry: lifecycle, counter, reconciliation
//! and CLI parsing.
mod common;
use agent_manager::cli::{Cli, Command};
use agent_manager::commands::sessions_cmd;
use agent_manager::sessions;
use clap::Parser;
#[test]
fn cli_parses_sessions() {
let cli = Cli::parse_from(["am", "sessions", "--status", "failed", "--show", "abc"]);
match &cli.command {
Some(Command::Sessions {
agent,
status,
show,
..
}) => {
assert!(agent.is_none());
assert_eq!(status.as_deref(), Some("failed"));
assert_eq!(show.as_deref(), Some("abc"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn agent_session_roundtrip_and_counter() {
let app = common::test_app("sess", &["sessions"]);
let id = sessions::start_agent(
&app,
"claude-code",
4242,
Some("/tmp".to_string()),
vec![],
vec![],
None,
)
.unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "running");
assert_eq!(r.agent.as_deref(), Some("claude-code"));
assert_eq!(app.state.load().unwrap().sessions_count, 1);
sessions::finish_agent(&app, "claude-code", Some(4242), 0, true).unwrap();
let sf = sessions::load(&app).unwrap();
let r = sf.sessions.get(&id).unwrap();
assert_eq!(r.status, "stopped");
assert_eq!(r.exit_code, Some(0));
assert!(r.ended_at.is_some());
}
#[test]
fn command_lists_sessions() {
let app = common::test_app("sesscmd", &["sessions"]);
sessions::start_agent(&app, "aider", 4242, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, None).unwrap();
assert_eq!(code, 0);
// --json path returns the row set without error.
sessions::finish_agent(&app, "aider", Some(4242), 0, true).unwrap();
}
#[test]
fn show_one_accepts_known_id() {
let app = common::test_app("sessshow", &["sessions"]);
let id = sessions::start_agent(&app, "jcode", 99, None, vec![], vec![], None).unwrap();
let code = sessions_cmd::run(&app, None, None, None, Some(&id)).unwrap();
assert_eq!(code, 0);
}