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