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:
@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user