feat(terminals): reprise de conversation par cellule + fix ordre d'écriture

Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:27:08 +02:00
parent d11eaaa8c0
commit 3ed0f6b45f
61 changed files with 5098 additions and 98 deletions

View File

@ -0,0 +1,359 @@
//! T7 tests for the [`InspectConversation`] use case (best-effort conversation
//! inspection feeding the resume popup).
//!
//! Each port is faked in-memory:
//! - [`FakeContexts`] — an [`AgentContextStore`] holding a manifest seeded with
//! one agent,
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
//! - [`FakeInspector`] — a [`SessionInspector`] whose `supports`/`details` are
//! configurable, so we can exercise: a supporting inspector returning details,
//! one returning `NotFound`, and the empty-`Vec` (no inspector) path.
//!
//! The contract under test (CONTEXT §T7 Part A): inspection is **best-effort** —
//! any miss (no inspector, unsupported profile, `NotFound`/`Read`) degrades to
//! empty details, never an error; only a genuine store failure errors.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use uuid::Uuid;
use application::{InspectConversation, InspectConversationInput};
// ---------------------------------------------------------------------------
// FakeContexts (AgentContextStore) — only load_manifest is exercised here
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<AgentManifest>>);
impl FakeContexts {
fn with_agent(agent: &Agent) -> Self {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: vec![ManifestEntry::from_agent(agent)],
})))
}
fn empty() -> Self {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
})))
}
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Ok(MarkdownDoc::new(""))
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save_manifest(
&self,
_project: &Project,
manifest: &AgentManifest,
) -> Result<(), StoreError> {
*self.0.lock().unwrap() = manifest.clone();
Ok(())
}
}
// ---------------------------------------------------------------------------
// FakeProfiles (ProfileStore)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FakeProfiles(Arc<Vec<AgentProfile>>);
#[async_trait]
impl ProfileStore for FakeProfiles {
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
Ok((*self.0).clone())
}
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
Ok(())
}
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
Ok(())
}
async fn is_configured(&self) -> Result<bool, StoreError> {
Ok(true)
}
async fn mark_configured(&self) -> Result<(), StoreError> {
Ok(())
}
}
// ---------------------------------------------------------------------------
// FakeInspector (SessionInspector) — configurable support + result
// ---------------------------------------------------------------------------
/// What the fake inspector returns from `details`.
#[derive(Clone)]
enum InspectResult {
Ok(ConversationDetails),
Err(InspectError),
}
/// Shared probe recording the `(conversation_id, cwd)` an inspection was asked
/// for (so a test can assert the run dir was routed, not the project root).
type SeenProbe = Arc<Mutex<Option<(String, String)>>>;
struct FakeInspector {
supports: bool,
result: InspectResult,
seen: SeenProbe,
}
impl FakeInspector {
fn new(supports: bool, result: InspectResult) -> (Arc<Self>, SeenProbe) {
let seen = Arc::new(Mutex::new(None));
let me = Arc::new(Self {
supports,
result,
seen: Arc::clone(&seen),
});
(me, seen)
}
}
#[async_trait]
impl SessionInspector for FakeInspector {
fn supports(&self, _profile: &AgentProfile) -> bool {
self.supports
}
async fn details(
&self,
_profile: &AgentProfile,
conversation_id: &str,
cwd: &ProjectPath,
) -> Result<ConversationDetails, InspectError> {
*self.seen.lock().unwrap() = Some((conversation_id.to_owned(), cwd.as_str().to_owned()));
match &self.result {
InspectResult::Ok(d) => Ok(d.clone()),
InspectResult::Err(e) => Err(e.clone()),
}
}
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)),
"demo",
ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn profile(id: ProfileId) -> AgentProfile {
AgentProfile::new(
id,
"Claude Code",
"claude",
Vec::new(),
ContextInjection::ConventionFile {
target: "CLAUDE.md".to_owned(),
},
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
}
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
Agent::new(id, "Bob", "agents/bob.md", profile_id, AgentOrigin::Scratch, false).unwrap()
}
fn input(agent_id: AgentId) -> InspectConversationInput {
InspectConversationInput {
project: project(),
agent_id,
conversation_id: "conv-123".to_owned(),
}
}
// ---------------------------------------------------------------------------
// (a) supporting inspector → details propagated
// ---------------------------------------------------------------------------
#[tokio::test]
async fn supporting_inspector_propagates_details() {
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let (inspector, seen) = FakeInspector::new(
true,
InspectResult::Ok(ConversationDetails {
last_topic: Some("refactor the parser".to_owned()),
token_count: Some(4242),
}),
);
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
vec![inspector],
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details.last_topic.as_deref(), Some("refactor the parser"));
assert_eq!(out.details.token_count, Some(4242));
// Routed the conversation id and the agent's isolated run dir (not the root).
let (conv, cwd) = seen.lock().unwrap().clone().expect("inspector was called");
assert_eq!(conv, "conv-123");
assert_eq!(cwd, format!("/home/me/proj/.ideai/run/{aid}"));
}
// ---------------------------------------------------------------------------
// (b) inspector returns NotFound → empty details, no error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn not_found_degrades_to_empty_details() {
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let (inspector, _seen) = FakeInspector::new(true, InspectResult::Err(InspectError::NotFound));
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
vec![inspector],
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details.last_topic, None);
assert_eq!(out.details.token_count, None);
}
// ---------------------------------------------------------------------------
// (b') inspector returns Read error → empty details, no error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn read_error_degrades_to_empty_details() {
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let (inspector, _seen) =
FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned())));
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
vec![inspector],
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details, ConversationDetails { last_topic: None, token_count: None });
}
// ---------------------------------------------------------------------------
// (c) empty Vec (no inspector) → empty details, no error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn no_inspector_yields_empty_details() {
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
Vec::new(),
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details.last_topic, None);
assert_eq!(out.details.token_count, None);
}
// ---------------------------------------------------------------------------
// (c') an inspector that does NOT support the profile is skipped → empty
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unsupported_inspector_is_skipped() {
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let (inspector, seen) = FakeInspector::new(
false, // does not support
InspectResult::Ok(ConversationDetails {
last_topic: Some("should never surface".to_owned()),
token_count: Some(1),
}),
);
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
vec![inspector],
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details.last_topic, None);
assert_eq!(out.details.token_count, None);
// details() must not have been called.
assert!(seen.lock().unwrap().is_none());
}
// ---------------------------------------------------------------------------
// Genuine resolution failures still error (unknown agent)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unknown_agent_errors() {
let aid = AgentId::from_uuid(Uuid::from_u128(99));
let (inspector, _seen) = FakeInspector::new(
true,
InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }),
);
let uc = InspectConversation::new(
Arc::new(FakeContexts::empty()),
Arc::new(FakeProfiles(Arc::new(Vec::new()))),
vec![inspector],
);
let err = uc.execute(input(aid)).await.unwrap_err();
// Should be a NOT_FOUND-class error, not a panic / empty success.
assert_eq!(err.code(), "NOT_FOUND");
}

View File

@ -26,9 +26,10 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -228,11 +229,22 @@ impl SkillStore for FakeSkills {
struct FakeRuntime {
trace: Trace,
plan: Option<ContextInjectionPlan>,
/// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests
/// can assert the launch resolved the right Assign/Resume/None intention (T4).
last_session: Arc<Mutex<Option<SessionPlan>>>,
}
impl FakeRuntime {
fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> Self {
Self { trace, plan }
Self {
trace,
plan,
last_session: Arc::new(Mutex::new(None)),
}
}
/// Shared handle to inspect the captured session plan after a launch.
fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
Arc::clone(&self.last_session)
}
}
@ -245,7 +257,9 @@ impl AgentRuntime for FakeRuntime {
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
*self.last_session.lock().unwrap() = Some(session.clone());
self.trace.lock().unwrap().push("prepare".to_owned());
Ok(SpawnSpec {
command: profile.command.clone(),
@ -282,6 +296,28 @@ impl FakeFs {
fn created_dirs(&self) -> Vec<String> {
self.created_dirs.lock().unwrap().clone()
}
/// Convention-file / context writes only (excludes the Claude permission seed),
/// so injection assertions stay focused on the agent's `.md`.
fn context_writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes()
.into_iter()
.filter(|(p, _)| !p.ends_with("/.claude/settings.local.json"))
.collect()
}
/// Only the Claude permission seed writes (`.claude/settings.local.json`).
fn seed_writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes()
.into_iter()
.filter(|(p, _)| p.ends_with("/.claude/settings.local.json"))
.collect()
}
/// Created run dirs excluding the seed's `.claude` subdir.
fn created_run_dirs(&self) -> Vec<String> {
self.created_dirs()
.into_iter()
.filter(|p| !p.ends_with("/.claude"))
.collect()
}
}
#[async_trait]
@ -443,6 +479,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
injection,
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
}
@ -602,15 +639,26 @@ type LaunchFixture = (
SpyBus,
Arc<TerminalSessions>,
Trace,
Arc<Mutex<Option<SessionPlan>>>,
);
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
launch_fixture_with_profile(profile(pid(9), injection), plan)
}
/// Like [`launch_fixture`] but takes a fully-built profile (so session-strategy
/// variants can be exercised). The seeded agent uses the profile's id.
fn launch_fixture_with_profile(
profile: AgentProfile,
plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
let profiles = FakeProfiles::new(vec![profile]);
let tr = trace();
let runtime = FakeRuntime::new(Arc::clone(&tr), plan);
let session_probe = runtime.session_probe();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let sessions = Arc::new(TerminalSessions::new());
@ -624,8 +672,9 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
Arc::new(FakeSkills::default()),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
);
(launch, agent, fs, pty, bus, sessions, tr)
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
}
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
@ -635,13 +684,14 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
rows: 24,
cols: 80,
node_id: None,
conversation_id: None,
}
}
#[tokio::test]
async fn launch_orders_prepare_then_injection_then_spawn() {
// conventionFile strategy → an fs.write must happen between prepare and spawn.
let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture(
let (launch, agent, fs, pty, bus, sessions, tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
@ -650,11 +700,17 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
// Ordering contract.
// Ordering contract. The Claude permission seed is written first (right after
// the run dir is created), then prepare → injection (convention file) → spawn.
assert_eq!(
*tr.lock().unwrap(),
vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()],
"prepare → injection → spawn"
vec![
"fs.write".to_owned(),
"prepare".to_owned(),
"fs.write".to_owned(),
"spawn".to_owned()
],
"seed → prepare → injection → spawn"
);
// The conventionFile was written inside the agent's isolated run directory
@ -662,7 +718,7 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
// is the *composed* document: an absolute project-root header followed by the
// agent persona `.md`.
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let writes = fs.writes();
let writes = fs.context_writes();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
let written = String::from_utf8(writes[0].1.clone()).unwrap();
@ -675,8 +731,20 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
"convention file must carry the agent persona, got: {written}"
);
// The run directory was created (via the FileSystem port) before spawn.
assert_eq!(fs.created_dirs(), vec![run_dir.clone()]);
// Bug #5: a Claude permission seed was written into the run dir so the agent
// runs with the project's autonomy instead of prompting per command.
let seeds = fs.seed_writes();
assert_eq!(seeds.len(), 1);
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
assert!(seed.contains("bypassPermissions"), "seed grants autonomy: {seed}");
assert!(seed.contains("/home/me/proj"), "seed grants the project root");
// The run directory (and the seed's `.claude` subdir) were created before spawn.
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
assert!(fs
.created_dirs()
.contains(&format!("{run_dir}/.claude")));
// Spawn happened at the isolated run dir with the profile command.
let spawns = pty.spawns();
@ -743,6 +811,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
Arc::new(FakeSkills::default()),
Arc::clone(&sessions),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
);
launch.execute(launch_input(agent_a.id)).await.unwrap();
@ -752,8 +821,8 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
// Two distinct run dirs were created.
assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]);
// Two distinct run dirs were created (ignoring each seed's `.claude` subdir).
assert_eq!(fs.created_run_dirs(), vec![dir_a.clone(), dir_b.clone()]);
// Two spawns at two distinct cwd — the core anti-collision guarantee.
let spawns = pty.spawns();
@ -763,7 +832,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
assert_ne!(spawns[0].cwd, spawns[1].cwd);
// Two convention files, each inside its own run dir (no shared root file).
let writes = fs.writes();
let writes = fs.context_writes();
assert_eq!(writes.len(), 2);
assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
@ -815,11 +884,12 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
Arc::new(skills),
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let writes = fs.writes();
let writes = fs.context_writes();
assert_eq!(writes.len(), 1);
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
assert!(doc.contains("# persona"), "persona present: {doc}");
@ -861,16 +931,42 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
);
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
let doc = String::from_utf8(fs.writes()[0].1.clone()).unwrap();
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
}
#[tokio::test]
async fn launch_non_claude_convention_does_not_seed_permissions() {
// A CLI whose convention file is NOT CLAUDE.md (e.g. Codex → AGENTS.md) must
// get its convention file but NO Claude permission seed (Bug #5 is per-CLI).
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("AGENTS.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
// The convention file was written, but no permission seed.
assert_eq!(fs.context_writes().len(), 1);
assert!(
fs.seed_writes().is_empty(),
"no Claude seed for a non-Claude CLI"
);
assert!(
!fs.created_dirs().iter().any(|p| p.ends_with("/.claude")),
"no .claude dir created for a non-Claude CLI"
);
}
#[tokio::test]
async fn launch_stdin_strategy_pipes_context_after_spawn() {
let (launch, agent, fs, pty, _bus, _sessions, tr) =
let (launch, agent, fs, pty, _bus, _sessions, tr, _session) =
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
launch.execute(launch_input(agent.id)).await.unwrap();
@ -886,7 +982,7 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
#[tokio::test]
async fn launch_unknown_agent_is_not_found() {
let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture(
let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
ContextInjection::stdin(),
Some(ContextInjectionPlan::Stdin),
);
@ -912,9 +1008,109 @@ async fn launch_unknown_profile_is_not_found() {
Arc::new(FakeSkills::default()),
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
);
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
}
// ---------------------------------------------------------------------------
// LaunchAgent — session resume (T4)
// ---------------------------------------------------------------------------
/// Builds a stdin profile carrying a [`SessionStrategy`] (assign + resume flags).
fn profile_with_session(
id: ProfileId,
assign_flag: Option<&str>,
resume_flag: &str,
) -> AgentProfile {
let session =
SessionStrategy::new(assign_flag.map(str::to_owned), resume_flag.to_owned()).unwrap();
AgentProfile::new(
id,
"Claude Code",
"claude",
Vec::new(),
ContextInjection::stdin(),
Some("claude --version".to_owned()),
"{agentRunDir}",
Some(session),
)
.unwrap()
}
#[tokio::test]
async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id() {
// Fresh cell (conversation_id = None), profile WITH an assign_flag.
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
profile_with_session(pid(9), Some("--session-id"), "--resume"),
Some(ContextInjectionPlan::Stdin),
);
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
// SeqIds yields Uuid::from_u128(1) on its first call.
let expected = Uuid::from_u128(1).to_string();
// The use case exposes the assigned id (caller persists it on the leaf).
assert_eq!(out.assigned_conversation_id.as_deref(), Some(expected.as_str()));
// The runtime received an Assign plan carrying exactly that id.
assert_eq!(
*session.lock().unwrap(),
Some(SessionPlan::Assign { conversation_id: expected }),
);
}
#[tokio::test]
async fn launch_reopen_with_existing_conversation_id_resumes_without_new_id() {
// The cell already carries a conversation id ⇒ Resume, no new id minted.
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
profile_with_session(pid(9), Some("--session-id"), "--resume"),
Some(ContextInjectionPlan::Stdin),
);
let mut input = launch_input(agent.id);
input.conversation_id = Some("conv-existing".to_owned());
let out = launch.execute(input).await.expect("launch");
// Nothing newly assigned (the id pre-existed): caller has nothing to persist.
assert_eq!(out.assigned_conversation_id, None);
// Resume plan with the existing id; SeqIds was never consulted.
assert_eq!(
*session.lock().unwrap(),
Some(SessionPlan::Resume {
conversation_id: "conv-existing".to_owned()
}),
);
}
#[tokio::test]
async fn launch_profile_without_session_block_passes_none() {
// Non-regression: a profile with no session block behaves exactly as before.
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) =
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
assert_eq!(out.assigned_conversation_id, None);
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}
#[tokio::test]
async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
// Profile HAS a session block but NO assign_flag (degraded): a first launch
// (no cell id) must NOT mint an id and must pass SessionPlan::None.
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
profile_with_session(pid(9), None, "--continue"),
Some(ContextInjectionPlan::Stdin),
);
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
assert_eq!(out.assigned_conversation_id, None, "degraded mode mints no id");
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}

View File

@ -191,6 +191,8 @@ fn single_leaf(node_id: NodeId) -> LayoutTree {
id: node_id,
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
})
}
@ -638,6 +640,77 @@ async fn mutate_set_cell_agent_missing_leaf_is_not_found() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
// ---------------------------------------------------------------------------
// SetCellConversation (T4b — persist the assigned CLI conversation id)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn mutate_set_cell_conversation_persists_id_on_leaf() {
let env = mut_env(pid(52)).await;
// Record a conversation id on the single root leaf (nid(1)).
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetCellConversation {
target: nid(1),
conversation_id: Some("conv-42".to_owned()),
},
})
.await
.expect("set_cell_conversation records the id");
// The id must survive in the persisted JSON, so the next open resumes.
let tree_json = active_tree_json(&env.fs);
assert_eq!(
tree_json["root"]["node"]["conversationId"], "conv-42",
"conversation id must be persisted on the leaf"
);
// Now clear it.
let out = env
.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetCellConversation {
target: nid(1),
conversation_id: None,
},
})
.await
.expect("set_cell_conversation clears the id");
match &out.layout.root {
LayoutNode::Leaf(l) => assert_eq!(l.conversation_id, None, "id must be cleared"),
_ => panic!("expected leaf root"),
}
assert!(
active_tree_json(&env.fs)["root"]["node"]
.get("conversationId")
.is_none(),
"cleared id must not be serialised"
);
}
#[tokio::test]
async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
let env = mut_env(pid(53)).await;
let err = env
.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetCellConversation {
target: nid(404),
conversation_id: Some("x".to_owned()),
},
})
.await
.expect_err("unknown node rejected");
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
// ---------------------------------------------------------------------------
// Named-layout management (#4)
// ---------------------------------------------------------------------------

View File

@ -22,7 +22,8 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::ids::SkillId;
use domain::profile::{AgentProfile, ContextInjection};
@ -231,6 +232,7 @@ impl AgentRuntime for FakeRuntime {
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
Ok(SpawnSpec {
command: profile.command.clone(),
@ -372,6 +374,7 @@ fn claude_profile() -> AgentProfile {
ContextInjection::stdin(),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
}
@ -410,6 +413,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(

View File

@ -13,7 +13,7 @@ use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SpawnSpec, StoreError,
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
@ -103,6 +103,7 @@ impl AgentRuntime for StubRuntime {
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
unreachable!("not used in these tests")
}
@ -117,6 +118,7 @@ fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
ContextInjection::stdin(),
Some(format!("{command} --version")),
"{projectRoot}",
None,
)
.unwrap()
}

View File

@ -0,0 +1,389 @@
//! T5 tests for [`SnapshotRunningAgents`].
//!
//! At close time, before the global PTY kill, the use case must freeze on each
//! agent-bearing leaf whether that agent's PTY was still live
//! (`agent_was_running`). Liveness is derived purely from the live-session
//! registry (a [`LiveAgentRegistry`]), never from CLI parsing.
//!
//! Every port is faked in-memory. We assert the persisted `layouts.json` flags
//! and the ordering guarantee (snapshot reads liveness BEFORE the kill).
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::layout::Workspace;
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
use domain::{
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
};
use uuid::Uuid;
use application::{LiveAgentRegistry, SnapshotRunningAgents, SnapshotRunningAgentsInput};
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeFsInner {
files: HashMap<String, Vec<u8>>,
dirs: HashSet<String>,
}
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);
impl FakeFs {
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
self.0.lock().unwrap().files.get(path).cloned()
}
fn put(&self, path: &str, data: &[u8]) {
self.0
.lock()
.unwrap()
.files
.insert(path.to_owned(), data.to_vec());
}
fn writes(&self) -> usize {
self.0.lock().unwrap().files.len()
}
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.0
.lock()
.unwrap()
.files
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.0
.lock()
.unwrap()
.files
.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
let inner = self.0.lock().unwrap();
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
#[derive(Default)]
struct FakeStoreInner {
projects: Vec<Project>,
}
#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
#[async_trait]
impl ProjectStore for FakeStore {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(self.0.lock().unwrap().projects.clone())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
self.0
.lock()
.unwrap()
.projects
.iter()
.find(|p| p.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
self.0.lock().unwrap().projects.push(project.clone());
Ok(())
}
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
Ok(())
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
Ok(Workspace::default())
}
}
/// A controllable liveness registry: an agent is "live" iff it is in the set.
#[derive(Default, Clone)]
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>);
impl FakeLive {
fn with(agents: &[AgentId]) -> Self {
Self(Arc::new(Mutex::new(agents.iter().copied().collect())))
}
/// Simulates a PTY kill: forget every live agent.
fn kill_all(&self) {
self.0.lock().unwrap().clear();
}
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.0.lock().unwrap().contains(agent_id)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ROOT: &str = "/home/me/proj";
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn lid(n: u128) -> LayoutId {
LayoutId::from_uuid(Uuid::from_u128(n))
}
fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
LeafCell {
id: node,
session: None,
agent,
conversation_id: None,
agent_was_running: false,
}
}
async fn register_project(store: &FakeStore, id: ProjectId) {
let project =
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
store.save_project(&project).await.unwrap();
}
/// Seeds a valid `layouts.json` with one active layout holding `tree`.
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
let doc = serde_json::json!({
"version": 1,
"activeId": id.to_string(),
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
});
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
}
fn doc_json(fs: &FakeFs) -> serde_json::Value {
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
}
/// Walks the active layout tree and returns `agent_was_running` for the leaf
/// `node`, or `None` if absent.
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
let doc = doc_json(fs);
let tree: LayoutTree =
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
match node {
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
}
}
find(&tree.root, node)
}
fn make_use_case(
store: &FakeStore,
fs: &FakeFs,
live: &FakeLive,
) -> SnapshotRunningAgents {
SnapshotRunningAgents::new(
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
Arc::new(live.clone()) as Arc<dyn LiveAgentRegistry>,
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// A live agent's leaf is persisted with `agent_was_running = true`.
#[tokio::test]
async fn live_agent_is_marked_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1);
assert_eq!(out.stopped, 0);
assert_eq!(was_running(&fs, leaf), Some(true));
}
/// An agent whose PTY has already exited is persisted with `false`.
#[tokio::test]
async fn stopped_agent_is_marked_not_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
// Registry is empty: the agent has no live session.
let live = FakeLive::default();
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 0);
assert_eq!(out.stopped, 1);
assert_eq!(was_running(&fs, leaf), Some(false));
}
/// Mixed tree (split with one live agent, one stopped agent, one plain leaf):
/// each agent leaf gets the right flag; the non-agent leaf is untouched.
#[tokio::test]
async fn mixed_tree_flags_each_agent_independently() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let live_leaf = nid(10);
let dead_leaf = nid(11);
let plain_leaf = nid(12);
let live_agent = aid(100);
let dead_agent = aid(101);
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(live_leaf, Some(live_agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(dead_leaf, Some(dead_agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(plain_leaf, None)),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
let live = FakeLive::with(&[live_agent]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1);
assert_eq!(out.stopped, 1);
assert_eq!(was_running(&fs, live_leaf), Some(true));
assert_eq!(was_running(&fs, dead_leaf), Some(false));
assert_eq!(was_running(&fs, plain_leaf), Some(false)); // default, untouched
}
/// Ordering guarantee: the snapshot reads liveness BEFORE the kill. Running the
/// snapshot on the live registry persists `true`; running it AFTER `kill_all`
/// (simulating a kill-then-snapshot mistake) would persist `false`.
#[tokio::test]
async fn snapshot_reads_liveness_before_kill() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let uc = make_use_case(&store, &fs, &live);
// Correct order (composition root contract): snapshot THEN kill.
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
live.kill_all();
assert_eq!(
was_running(&fs, leaf),
Some(true),
"snapshot taken before kill must record running"
);
// Re-seed and demonstrate the opposite order yields `false` — proving the
// flag is sensitive to registry state at call time (hence order matters).
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(
was_running(&fs, leaf),
Some(false),
"snapshot taken after kill records not-running"
);
}
/// A layout with no agent leaf is a no-op: nothing is written.
#[tokio::test]
async fn no_agent_leaf_is_noop() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf = nid(10);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, None)));
let writes_before = fs.writes();
let live = FakeLive::default();
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 0);
assert_eq!(out.stopped, 0);
// No agent leaf → no persistence triggered (file count unchanged).
assert_eq!(fs.writes(), writes_before);
assert_eq!(was_running(&fs, leaf), Some(false));
}

View File

@ -61,6 +61,8 @@ fn tab(n: u128) -> Tab {
id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
})),
}
}