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

@ -15,9 +15,9 @@ use async_trait::async_trait;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SpawnSpec,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath;
use domain::ids::ProfileId;
use domain::MarkdownDoc;
@ -36,6 +36,7 @@ fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile {
injection,
Some("mycli probe --json".to_owned()),
cwd_template,
None,
)
.unwrap()
}
@ -97,7 +98,7 @@ fn prepare_convention_file_keeps_args_and_plans_file() {
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
@ -123,7 +124,7 @@ fn prepare_flag_with_path_substitutes_and_splits() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
// static args first, then the substituted+split flag args.
assert_eq!(
@ -148,7 +149,7 @@ fn prepare_flag_without_path_is_switch_then_path() {
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
assert_eq!(
@ -169,7 +170,7 @@ fn prepare_stdin_keeps_args_and_plans_stdin() {
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
@ -188,7 +189,7 @@ fn prepare_env_keeps_args_and_plans_env() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
assert_eq!(
@ -212,7 +213,7 @@ fn prepare_substitutes_project_root_in_cwd_template() {
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
}
@ -222,7 +223,7 @@ fn prepare_empty_cwd_template_defaults_to_base() {
let p = profile(ContextInjection::stdin(), "");
let base = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &base).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
}
@ -234,7 +235,7 @@ fn prepare_substitutes_agent_run_dir_in_cwd_template() {
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir).unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None).unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
}
@ -262,6 +263,7 @@ fn detection_spec_falls_back_to_command_version() {
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap();
@ -327,3 +329,190 @@ fn detect_runs_the_detection_spec_command() {
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["probe", "--json"]);
}
// ---------------------------------------------------------------------------
// prepare_invocation — session args (T3 truth table)
// ---------------------------------------------------------------------------
/// Like [`profile`] but carries a [`SessionStrategy`]. `Stdin` injection keeps
/// the context out of the args so session args are the *only* trailing tokens.
fn profile_with_session(session: Option<SessionStrategy>) -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(7)),
"Test",
"mycli",
vec!["--static".to_owned(), "arg".to_owned()],
ContextInjection::stdin(),
Some("mycli probe --json".to_owned()),
"{agentRunDir}",
session,
)
.unwrap()
}
fn root() -> ProjectPath {
ProjectPath::new("/p").unwrap()
}
// Row 1: profile.session == None ⇒ no args added, whatever the plan.
#[test]
fn session_none_profile_adds_nothing_for_any_plan() {
let rt = pure_runtime();
let p = profile_with_session(None);
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "id-1".to_owned() },
SessionPlan::Resume { conversation_id: "id-1".to_owned() },
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
}
}
// Row 2: Some{assign_flag: Some(f)} + Assign{id} ⇒ [f, id].
#[test]
fn session_assign_with_flag_emits_flag_and_id() {
let rt = pure_runtime();
let p = profile_with_session(Some(
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
));
let spec = rt
.prepare_invocation(
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "--session-id", "abc"]);
}
// Row 3: Some{resume_flag: r, assign_flag: Some} + Resume{id} ⇒ [r, id].
#[test]
fn session_resume_with_flag_emits_resume_and_id() {
let rt = pure_runtime();
let p = profile_with_session(Some(
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
));
let spec = rt
.prepare_invocation(
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "--resume", "abc"]);
}
// Row 4: Some{assign_flag: None, resume_flag: r} + Resume{id} ⇒ [r] (degraded).
#[test]
fn session_resume_without_assign_flag_emits_resume_only() {
let rt = pure_runtime();
let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));
let spec = rt
.prepare_invocation(
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "--continue"]);
}
// Row 5: Some{..} + SessionPlan::None ⇒ nothing added.
#[test]
fn session_plan_none_with_strategy_adds_nothing() {
let rt = pure_runtime();
let p = profile_with_session(Some(
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
));
let spec = rt
.prepare_invocation(&p, &ctx(), &root(), &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"]);
}
// Row 6: Some{assign_flag: None} + Assign{id} ⇒ nothing (no assign possible).
#[test]
fn session_assign_without_flag_adds_nothing() {
let rt = pure_runtime();
let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));
let spec = rt
.prepare_invocation(
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"]);
}
// Non-regression: a profile WITHOUT session + SessionPlan::None yields the exact
// same args as before T3 (the existing strategy tests already assert these; here
// we pin it against an Assign/Resume plan too — still nothing added).
#[test]
fn no_session_profile_is_unaffected_by_any_plan() {
let rt = pure_runtime();
let p = profile(ContextInjection::stdin(), "{agentRunDir}");
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "x".to_owned() },
SessionPlan::Resume { conversation_id: "x".to_owned() },
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
}
}
// Ordering: session args land *after* the context-injection (Flag) args.
#[test]
fn session_args_come_after_context_injection_args() {
let rt = pure_runtime();
let p = AgentProfile::new(
ProfileId::from_uuid(uuid::Uuid::from_u128(8)),
"Test",
"mycli",
vec!["--static".to_owned()],
ContextInjection::flag("--context-file {path}").unwrap(),
Some("mycli probe".to_owned()),
"{agentRunDir}",
Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()),
)
.unwrap();
let spec = rt
.prepare_invocation(
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
)
.unwrap();
// static arg, then context-injection args, then session args — in that order.
assert_eq!(
spec.args,
vec![
"--static",
"--context-file",
".ideai/agent.md",
"--session-id",
"abc"
]
);
}

View File

@ -0,0 +1,178 @@
//! T6 integration tests for [`ClaudeTranscriptInspector`] against a real temp
//! directory and a real [`LocalFileSystem`], exercising the full transcript
//! discovery + parsing path:
//!
//! - a realistic `~/.claude/projects/<encoded-cwd>/<id>.jsonl` fixture →
//! `last_topic` + `token_count` extracted;
//! - a missing transcript → [`InspectError::NotFound`];
//! - malformed lines in the middle → skipped, valid lines still extracted;
//! - [`SessionInspector::supports`] true for a `CLAUDE.md` profile, false for an
//! `AGENTS.md` one or a non-`conventionFile` injection.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{FileSystem, InspectError, SessionInspector};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
use infrastructure::{ClaudeTranscriptInspector, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop. Plays
/// the role of the user's `$HOME` so the inspector reads
/// `<home>/.claude/projects/...` entirely inside the fixture.
struct TempHome(PathBuf);
impl TempHome {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-t6-claude-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> String {
self.0.to_string_lossy().into_owned()
}
/// Writes a transcript at `<home>/.claude/projects/<encoded-cwd>/<id>.jsonl`,
/// mirroring the adapter's own cwd-encoding convention.
fn write_transcript(&self, cwd: &str, conversation_id: &str, body: &str) {
let encoded: String = cwd
.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect();
let dir = self.0.join(".claude").join("projects").join(&encoded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{conversation_id}.jsonl")), body).unwrap();
}
}
impl Drop for TempHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn inspector(home: &TempHome) -> ClaudeTranscriptInspector {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
ClaudeTranscriptInspector::with_base_dir(fs, home.path())
}
fn claude_profile() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(1)),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
}
#[tokio::test]
async fn extracts_last_topic_and_token_count_from_fixture() {
let home = TempHome::new();
let cwd = "/home/dev/Projects/Demo";
let id = "11111111-2222-3333-4444-555555555555";
let body = concat!(
r#"{"role":"user","content":"set up the project"}"#,
"\n",
r#"{"role":"assistant","content":"on it","usage":{"input_tokens":12,"output_tokens":8}}"#,
"\n",
r#"{"role":"user","content":"now add tests"}"#,
"\n",
r#"{"role":"assistant","content":"done","usage":{"input_tokens":30,"output_tokens":20}}"#,
"\n",
);
home.write_transcript(cwd, id, body);
let insp = inspector(&home);
let details = insp
.details(&claude_profile(), id, &ProjectPath::new(cwd).unwrap())
.await
.expect("transcript should be found and parsed");
assert_eq!(details.last_topic.as_deref(), Some("now add tests"));
assert_eq!(details.token_count, Some(70));
}
#[tokio::test]
async fn missing_transcript_is_not_found() {
let home = TempHome::new();
let insp = inspector(&home);
let err = insp
.details(
&claude_profile(),
"does-not-exist",
&ProjectPath::new("/home/dev/nope").unwrap(),
)
.await
.expect_err("absent transcript must error");
assert_eq!(err, InspectError::NotFound);
}
#[tokio::test]
async fn malformed_lines_are_skipped_not_fatal() {
let home = TempHome::new();
let cwd = "/home/dev/Projects/Resilient";
let id = "aaaa";
let body = concat!(
r#"{"role":"user","content":"valid first"}"#,
"\n",
"{ this is not valid json",
"\n",
"plain garbage line",
"\n",
r#"{"role":"assistant","usage":{"input_tokens":5,"output_tokens":6}}"#,
"\n",
r#"{"role":"user","content":"valid last"}"#,
"\n",
);
home.write_transcript(cwd, id, body);
let insp = inspector(&home);
let details = insp
.details(&claude_profile(), id, &ProjectPath::new(cwd).unwrap())
.await
.expect("readable transcript with bad lines should still parse");
assert_eq!(details.last_topic.as_deref(), Some("valid last"));
assert_eq!(details.token_count, Some(11));
}
#[test]
fn supports_only_claude_md_profiles() {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
let insp = ClaudeTranscriptInspector::new(fs, "/tmp/whatever");
// CLAUDE.md → supported.
assert!(insp.supports(&claude_profile()));
// AGENTS.md (Codex) → not supported.
let codex = AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(2)),
"Codex",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
None,
"{agentRunDir}",
None,
)
.unwrap();
assert!(!insp.supports(&codex));
// Non-conventionFile injection (stdin) → not supported.
let stdin_profile = AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(3)),
"Piped",
"aider",
Vec::new(),
ContextInjection::stdin(),
None,
"{agentRunDir}",
None,
)
.unwrap();
assert!(!insp.supports(&stdin_profile));
}

View File

@ -21,7 +21,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};
@ -183,6 +184,7 @@ impl AgentRuntime for FakeRuntime {
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
Ok(SpawnSpec {
command: profile.command.clone(),
@ -283,6 +285,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
ContextInjection::stdin(),
None,
"{agentRunDir}",
None,
)
.unwrap()])));
let sessions = Arc::new(TerminalSessions::new());
@ -301,6 +304,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));

View File

@ -46,6 +46,7 @@ fn sample(id: u128, name: &str, command: &str) -> AgentProfile {
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(format!("{command} --version")),
"{projectRoot}",
None,
)
.unwrap()
}