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

@ -21,6 +21,15 @@ pub enum Direction {
Column,
}
/// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if`
/// predicate so that default (`false`) flags are omitted from the serialized
/// form, preserving backward/forward compatibility with leaves that predate the
/// field.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
/// A leaf cell hosting zero or one terminal session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -33,6 +42,15 @@ pub struct LeafCell {
/// The agent to launch automatically in this cell, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentId>,
/// Opaque CLI conversation id, **persistent** across the cell's lifetime
/// (distinct from the ephemeral PTY [`SessionId`]). Enables resuming a CLI
/// conversation after the terminal/PTY has been closed and reopened.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// Whether the cell's agent process was running at the moment the cell was
/// last closed. Used to decide whether to auto-resume the agent on reopen.
#[serde(default, skip_serializing_if = "is_false")]
pub agent_was_running: bool,
}
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
@ -349,6 +367,8 @@ impl LayoutTree {
id: leaf.id,
session: None,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
if leaf.id == to {
@ -356,6 +376,8 @@ impl LayoutTree {
id: leaf.id,
session: Some(session),
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -392,6 +414,8 @@ impl LayoutTree {
id: leaf.id,
session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -427,6 +451,8 @@ impl LayoutTree {
id: leaf.id,
session: leaf.session,
agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
@ -440,6 +466,113 @@ impl LayoutTree {
Ok(tree)
}
/// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on
/// the leaf `target`.
///
/// Unlike [`Self::set_session`] (the ephemeral PTY binding), the conversation
/// id survives PTY close/reopen and is what lets the agent CLI *resume* its
/// previous conversation.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_cell_conversation(
&self,
target: NodeId,
conversation_id: Option<String>,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: conversation_id.clone(),
agent_was_running: leaf.agent_was_running,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Records whether the cell's agent process was `running` at close time, on
/// the leaf `target`.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_agent_running(
&self,
target: NodeId,
running: bool,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
return LayoutNode::Leaf(LeafCell {
id: leaf.id,
session: leaf.session,
agent: leaf.agent,
conversation_id: leaf.conversation_id.clone(),
agent_was_running: running,
});
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Collects every leaf that carries an agent, as `(leaf id, agent id)` pairs.
///
/// Used by the close-time snapshot of running agents: the application walks
/// these leaves and records, on each, whether the agent's PTY was still live
/// (`agent_was_running`) before the global PTY kill. Pure read-only traversal.
#[must_use]
pub fn agent_leaves(&self) -> Vec<(NodeId, AgentId)> {
fn walk(node: &LayoutNode, out: &mut Vec<(NodeId, AgentId)>) {
match node {
LayoutNode::Leaf(leaf) => {
if let Some(agent) = leaf.agent {
out.push((leaf.id, agent));
}
}
LayoutNode::Split(split) => {
for child in &split.children {
walk(&child.node, out);
}
}
LayoutNode::Grid(grid) => {
for cell in &grid.cells {
walk(&cell.node, out);
}
}
}
}
let mut out = Vec::new();
walk(&self.root, &mut out);
out
}
/// Returns `Ok(Some(session))` / `Ok(None)` for the session held by the leaf
/// `id`, or [`LayoutError::NodeNotFound`] if no such leaf exists.
fn session_in_leaf(&self, id: NodeId) -> Result<Option<SessionId>, LayoutError> {

View File

@ -67,7 +67,7 @@ pub use skill::{Skill, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{AgentProfile, ContextInjection};
pub use profile::{AgentProfile, ContextInjection, SessionStrategy};
pub use markdown::MarkdownDoc;

View File

@ -65,6 +65,23 @@ pub enum ContextInjectionPlan {
},
}
/// Intention de session pour un lancement d'agent donné.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionPlan {
/// Pas de reprise : profil sans bloc `session`, ou cellule neuve sans id.
None,
/// Premier lancement : IdeA a généré `conversation_id`, à assigner via assign_flag.
Assign {
/// Identifiant de conversation généré par IdeA pour ce lancement.
conversation_id: String,
},
/// Réouverture : reprendre la conversation existante via resume_flag.
Resume {
/// Identifiant de la conversation existante à reprendre.
conversation_id: String,
},
}
/// A fully-resolved process invocation: command, args, cwd, environment, and the
/// plan for delivering the agent context.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -91,6 +108,22 @@ pub struct PreparedContext {
pub relative_path: String,
}
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
/// on-disk transcript format (CONTEXT §T6).
///
/// Every field is optional: the core never *requires* this data, and a missing
/// piece (or a missing inspector) must never block a resume. The values exist
/// purely to enrich a resume popup (last topic, token indicator).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationDetails {
/// A short, best-effort label for the conversation (e.g. the last user
/// message, truncated). `None` when it cannot be extracted.
pub last_topic: Option<String>,
/// A best-effort cumulative token count for the conversation. `None` when no
/// usage information is available.
pub token_count: Option<u64>,
}
/// An opaque handle to a live PTY, owned by the adapter.
///
/// The domain only needs an identity to address the PTY in subsequent calls;
@ -235,6 +268,21 @@ pub enum RemoteError {
Auth(String),
}
/// Errors from a [`SessionInspector`].
///
/// Inspection is **best-effort and optional** (CONTEXT §T6/T7): these errors
/// must never block a conversation resume. A caller routing several inspectors
/// simply treats any error as "no enriched details available".
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum InspectError {
/// No transcript was found for the requested conversation.
#[error("conversation transcript not found")]
NotFound,
/// The transcript could not be read.
#[error("conversation transcript read failed: {0}")]
Read(String),
}
/// Errors from the git [`GitPort`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum GitError {
@ -309,6 +357,7 @@ pub trait AgentRuntime: Send + Sync {
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError>;
}
@ -707,6 +756,39 @@ pub trait GitPort: Send + Sync {
async fn push(&self, root: &ProjectPath) -> Result<(), GitError>;
}
/// **Optional** capability to read enriched details from a CLI's conversation
/// transcript (CONTEXT §T6).
///
/// This port is optional *by construction*: it backs a best-effort resume popup
/// and is never wired as a hard dependency of the launch/resume flow. A caller
/// (the future `InspectConversation` use case, §T7) holds a
/// `Vec<Arc<dyn SessionInspector>>` and routes a profile to the first inspector
/// whose [`supports`](Self::supports) returns `true`; if none matches, or the
/// call errors, the resume proceeds with no enriched details.
///
/// All Claude/Codex/Gemini transcript shapes stay **inside the adapter**: no
/// CLI-specific type ever crosses this boundary — only [`ConversationDetails`].
#[async_trait]
pub trait SessionInspector: Send + Sync {
/// Returns whether this inspector knows how to read transcripts for the
/// given profile (e.g. by recognising its context-injection convention).
fn supports(&self, profile: &AgentProfile) -> bool;
/// Reads best-effort [`ConversationDetails`] for `conversation_id` whose
/// agent runs in `cwd`.
///
/// # Errors
/// [`InspectError::NotFound`] when no transcript exists for the conversation;
/// [`InspectError::Read`] on an I/O / decoding failure. Malformed *lines*
/// inside an otherwise-readable transcript are skipped, not surfaced.
async fn details(
&self,
profile: &AgentProfile,
conversation_id: &str,
cwd: &ProjectPath,
) -> Result<ConversationDetails, InspectError>;
}
/// Publish/subscribe domain events. Synchronous, in-process.
pub trait EventBus: Send + Sync {
/// Publishes an event.

View File

@ -76,11 +76,54 @@ impl ContextInjection {
}
}
/// Declares how IdeA assigns and resumes a CLI conversation, without parsing
/// the model's output (universal, declarative — see CONTEXT.md §9).
///
/// Optional on a profile: a profile without a `session` block behaves as today
/// (no resume).
///
/// Invariants:
/// - `resume_flag` is non-empty,
/// - if `assign_flag` is `Some`, it is non-empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionStrategy {
/// Flag passed on the FIRST launch with a UUID generated by IdeA
/// (e.g. `"--session-id"`). `None` => degraded mode (resume without id).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assign_flag: Option<String>,
/// Resume flag (e.g. `"--resume"` or `"--continue"`).
pub resume_flag: String,
}
impl SessionStrategy {
/// Builds a validated session strategy.
///
/// # Errors
/// Returns [`DomainError::EmptyField`] if `resume_flag` is empty, or if
/// `assign_flag` is `Some` but empty.
pub fn new(
assign_flag: Option<String>,
resume_flag: impl Into<String>,
) -> Result<Self, DomainError> {
let resume_flag = resume_flag.into();
crate::validation::non_empty(&resume_flag, "session.resumeFlag")?;
if let Some(flag) = &assign_flag {
crate::validation::non_empty(flag, "session.assignFlag")?;
}
Ok(Self {
assign_flag,
resume_flag,
})
}
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
/// - `name` and `command` non-empty,
/// - `context_injection` is itself valid (guaranteed by its constructors).
/// - `context_injection` is itself valid (guaranteed by its constructors),
/// - `session` is itself valid (guaranteed by its constructor).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
@ -101,6 +144,10 @@ pub struct AgentProfile {
/// **never** the project root, so that N agents of the same profile never
/// collide on a single conventional file (`CLAUDE.md`, …) at the root.
pub cwd_template: String,
/// Optional conversation assign/resume strategy. Absent (legacy / no resume)
/// keeps today's behaviour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionStrategy>,
}
impl AgentProfile {
@ -117,6 +164,7 @@ impl AgentProfile {
context_injection: ContextInjection,
detect: Option<String>,
cwd_template: impl Into<String>,
session: Option<SessionStrategy>,
) -> Result<Self, DomainError> {
let name = name.into();
let command = command.into();
@ -131,6 +179,7 @@ impl AgentProfile {
context_injection,
detect,
cwd_template,
session,
})
}
}

View File

@ -5,8 +5,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, Skill, SkillId,
SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy,
Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
};
use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid;
@ -180,6 +180,7 @@ fn profile_valid() {
ci_stdin(),
Some("claude --version".into()),
"{projectRoot}",
None,
);
assert!(p.is_ok());
}
@ -194,6 +195,7 @@ fn profile_rejects_empty_command() {
ci_stdin(),
None,
"{projectRoot}",
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.command"));
@ -201,11 +203,36 @@ fn profile_rejects_empty_command() {
#[test]
fn profile_rejects_empty_name() {
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}")
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
}
#[test]
fn session_strategy_valid_with_assign_flag() {
let s = SessionStrategy::new(Some("--session-id".into()), "--resume");
assert!(s.is_ok());
}
#[test]
fn session_strategy_valid_without_assign_flag() {
let s = SessionStrategy::new(None, "--continue").unwrap();
assert_eq!(s.assign_flag, None);
assert_eq!(s.resume_flag, "--continue");
}
#[test]
fn session_strategy_rejects_empty_resume_flag() {
let err = SessionStrategy::new(Some("--session-id".into()), "").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "session.resumeFlag"));
}
#[test]
fn session_strategy_rejects_empty_assign_flag() {
let err = SessionStrategy::new(Some(String::new()), "--resume").unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "session.assignFlag"));
}
// ---------------------------------------------------------------------------
// RemoteRef invariants
// ---------------------------------------------------------------------------

View File

@ -19,9 +19,36 @@ fn leaf(id: u128, sess: Option<u128>) -> LeafCell {
id: node(id),
session: sess.map(session),
agent: None,
conversation_id: None,
agent_was_running: false,
}
}
/// A leaf pre-populated with the two resume fields, used by the
/// non-regression tests that assert these fields survive other operations.
fn leaf_with_resume(id: u128, sess: Option<u128>) -> LeafCell {
LeafCell {
id: node(id),
session: sess.map(session),
agent: None,
conversation_id: Some("conv-1".to_string()),
agent_was_running: true,
}
}
/// Walks the public tree to fetch the full [`LeafCell`] for `id`.
fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option<LeafCell> {
fn walk(n: &LayoutNode, id: domain::NodeId) -> Option<LeafCell> {
match n {
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)),
}
}
walk(&tree.root, id)
}
fn single(id: u128, sess: Option<u128>) -> LayoutTree {
LayoutTree::single(leaf(id, sess))
}
@ -493,3 +520,372 @@ fn set_cell_agent_preserves_session() {
_ => panic!("expected leaf"),
}
}
// ---------------------------------------------------------------------------
// set_cell_conversation (resume: persistent CLI conversation id)
// ---------------------------------------------------------------------------
#[test]
fn set_cell_conversation_attaches_to_leaf() {
let tree = single(1, None);
let out = tree
.set_cell_conversation(node(1), Some("conv-xyz".to_string()))
.unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.conversation_id, Some("conv-xyz".to_string()));
}
#[test]
fn set_cell_conversation_clears_with_none() {
let tree = LayoutTree::single(leaf_with_resume(1, None));
let out = tree.set_cell_conversation(node(1), None).unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.conversation_id, None);
}
#[test]
fn set_cell_conversation_targets_correct_leaf() {
// Two leaves; only leaf 2 should receive the conversation id.
let tree = two_leaves(None, None);
let out = tree
.set_cell_conversation(node(2), Some("conv-2".to_string()))
.unwrap();
assert_eq!(
leaf_for(&out, node(1)).unwrap().conversation_id,
None,
"untouched leaf must stay clear"
);
assert_eq!(
leaf_for(&out, node(2)).unwrap().conversation_id,
Some("conv-2".to_string())
);
}
#[test]
fn set_cell_conversation_is_immutable_source_unchanged() {
let tree = single(1, None);
let before = tree.clone();
let _ = tree
.set_cell_conversation(node(1), Some("conv-1".to_string()))
.unwrap();
assert_eq!(tree, before, "source tree must not be mutated");
}
#[test]
fn set_cell_conversation_missing_leaf_is_node_not_found() {
let tree = single(1, None);
let err = tree
.set_cell_conversation(node(404), Some("conv".to_string()))
.unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn set_cell_conversation_preserves_session_and_agent_running() {
// conversation id must coexist with session and agent_was_running.
let tree = LayoutTree::single(leaf_with_resume(1, Some(100)));
let out = tree
.set_cell_conversation(node(1), Some("conv-new".to_string()))
.unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.session, Some(session(100)));
assert_eq!(l.agent_was_running, true);
assert_eq!(l.conversation_id, Some("conv-new".to_string()));
}
// ---------------------------------------------------------------------------
// set_agent_running (resume: agent process state at close)
// ---------------------------------------------------------------------------
#[test]
fn set_agent_running_sets_true_then_false() {
let tree = single(1, None);
let out = tree.set_agent_running(node(1), true).unwrap();
assert_eq!(leaf_for(&out, node(1)).unwrap().agent_was_running, true);
let out2 = out.set_agent_running(node(1), false).unwrap();
assert_eq!(leaf_for(&out2, node(1)).unwrap().agent_was_running, false);
}
#[test]
fn set_agent_running_targets_correct_leaf() {
let tree = two_leaves(None, None);
let out = tree.set_agent_running(node(2), true).unwrap();
assert_eq!(
leaf_for(&out, node(1)).unwrap().agent_was_running,
false,
"untouched leaf must stay false"
);
assert_eq!(leaf_for(&out, node(2)).unwrap().agent_was_running, true);
}
// ---------------------------------------------------------------------------
// agent_leaves (close-time snapshot traversal)
// ---------------------------------------------------------------------------
#[test]
fn agent_leaves_empty_when_no_agent() {
let tree = two_leaves(Some(1), None);
assert!(tree.agent_leaves().is_empty());
}
#[test]
fn agent_leaves_collects_only_agent_bearing_leaves() {
// Split: leaf 1 with agent 100, leaf 2 plain, leaf 3 with agent 101.
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(99),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(1),
session: None,
agent: Some(agent_id(100)),
conversation_id: None,
agent_was_running: false,
}),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(leaf(2, None)),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(3),
session: None,
agent: Some(agent_id(101)),
conversation_id: None,
agent_was_running: false,
}),
weight: 1.0,
},
],
}));
let mut found = tree.agent_leaves();
found.sort_by_key(|(n, _)| *n);
assert_eq!(
found,
vec![(node(1), agent_id(100)), (node(3), agent_id(101))]
);
}
#[test]
fn set_agent_running_is_immutable_source_unchanged() {
let tree = single(1, None);
let before = tree.clone();
let _ = tree.set_agent_running(node(1), true).unwrap();
assert_eq!(tree, before, "source tree must not be mutated");
}
#[test]
fn set_agent_running_missing_leaf_is_node_not_found() {
let tree = single(1, None);
let err = tree.set_agent_running(node(404), true).unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}
#[test]
fn set_agent_running_preserves_session_and_conversation() {
let tree = LayoutTree::single(leaf_with_resume(1, Some(100)));
let out = tree.set_agent_running(node(1), false).unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.session, Some(session(100)));
assert_eq!(l.conversation_id, Some("conv-1".to_string()));
assert_eq!(l.agent_was_running, false);
}
// ---------------------------------------------------------------------------
// NON-REGRESSION (piège n°1): the resume fields (conversation_id,
// agent_was_running) MUST survive every leaf-rebuilding operation.
// ---------------------------------------------------------------------------
#[test]
fn set_session_preserves_resume_fields() {
// leaf starts with conversation_id = Some("conv-1") and agent_was_running = true.
let tree = LayoutTree::single(leaf_with_resume(1, None));
let out = tree.set_session(node(1), Some(session(100))).unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.session, Some(session(100)));
assert_eq!(
l.conversation_id,
Some("conv-1".to_string()),
"set_session must NOT erase conversation_id"
);
assert_eq!(
l.agent_was_running, true,
"set_session must NOT erase agent_was_running"
);
}
#[test]
fn set_cell_agent_preserves_resume_fields() {
let tree = LayoutTree::single(leaf_with_resume(1, None));
let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap();
let l = leaf_for(&out, node(1)).expect("leaf");
assert_eq!(l.agent, Some(agent_id(42)));
assert_eq!(
l.conversation_id,
Some("conv-1".to_string()),
"set_cell_agent must NOT erase conversation_id"
);
assert_eq!(
l.agent_was_running, true,
"set_cell_agent must NOT erase agent_was_running"
);
}
#[test]
fn move_session_preserves_resume_fields_on_both_leaves() {
// from-leaf carries a session + resume fields; to-leaf is empty but ALSO
// carries resume fields. After the move, BOTH leaves must keep their own
// conversation_id / agent_was_running (only the session moves).
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(9),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(1),
session: Some(session(100)),
agent: None,
conversation_id: Some("conv-from".to_string()),
agent_was_running: true,
}),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(2),
session: None,
agent: None,
conversation_id: Some("conv-to".to_string()),
agent_was_running: true,
}),
weight: 1.0,
},
],
}));
let out = tree.move_session(node(1), node(2)).unwrap();
let from = leaf_for(&out, node(1)).expect("from leaf");
assert_eq!(from.session, None, "session left the from-leaf");
assert_eq!(
from.conversation_id,
Some("conv-from".to_string()),
"move_session must NOT erase from-leaf conversation_id"
);
assert_eq!(
from.agent_was_running, true,
"move_session must NOT erase from-leaf agent_was_running"
);
let to = leaf_for(&out, node(2)).expect("to leaf");
assert_eq!(to.session, Some(session(100)), "session arrived at to-leaf");
assert_eq!(
to.conversation_id,
Some("conv-to".to_string()),
"move_session must NOT erase to-leaf conversation_id"
);
assert_eq!(
to.agent_was_running, true,
"move_session must NOT erase to-leaf agent_was_running"
);
}
// ---------------------------------------------------------------------------
// serde: compat ascendante & combinaisons des nouveaux champs
// ---------------------------------------------------------------------------
#[test]
fn leaf_serde_all_four_combinations_roundtrip() {
let combos = [
(None, false),
(Some("c".to_string()), false),
(None, true),
(Some("c".to_string()), true),
];
for (conv, running) in combos {
let cell = LeafCell {
id: node(1),
session: None,
agent: None,
conversation_id: conv.clone(),
agent_was_running: running,
};
let json = serde_json::to_string(&cell).unwrap();
let back: LeafCell = serde_json::from_str(&json).unwrap();
assert_eq!(back, cell, "roundtrip failed for {conv:?}/{running}");
}
}
#[test]
fn leaf_serde_omits_defaults() {
let cell = LeafCell {
id: node(1),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
};
let json = serde_json::to_string(&cell).unwrap();
assert!(
!json.contains("conversationId"),
"default conversation_id must be omitted; json was {json}"
);
assert!(
!json.contains("agentWasRunning"),
"default agent_was_running must be omitted; json was {json}"
);
}
#[test]
fn leaf_serde_field_names_are_camel_case_when_present() {
let cell = LeafCell {
id: node(1),
session: None,
agent: None,
conversation_id: Some("c".to_string()),
agent_was_running: true,
};
let json = serde_json::to_string(&cell).unwrap();
assert!(json.contains("conversationId"), "json was {json}");
assert!(json.contains("agentWasRunning"), "json was {json}");
}
#[test]
fn legacy_leaf_json_deserialises_to_defaults() {
// A leaf persisted before these fields existed (only id present).
let json = r#"{"id":"00000000-0000-0000-0000-000000000001"}"#;
let cell: LeafCell = serde_json::from_str(json).unwrap();
assert_eq!(cell.conversation_id, None);
assert_eq!(cell.agent_was_running, false);
assert_eq!(cell.session, None);
assert_eq!(cell.agent, None);
}
#[test]
fn leaf_can_carry_conversation_without_session_and_inversely() {
// conversation_id present, no session.
let a = LeafCell {
id: node(1),
session: None,
agent: None,
conversation_id: Some("c".to_string()),
agent_was_running: false,
};
let a_back: LeafCell = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
assert_eq!(a_back, a);
// session present, no conversation_id.
let b = LeafCell {
id: node(2),
session: Some(session(7)),
agent: None,
conversation_id: None,
agent_was_running: false,
};
let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap();
assert_eq!(b_back, b);
}

View File

@ -6,7 +6,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion,
WeightedChild,
};
use helpers::{node, session};
use uuid::Uuid;
@ -108,6 +109,7 @@ fn profile_roundtrip_all_injection_variants() {
ci,
Some("claude --version".into()),
"{projectRoot}",
None,
)
.unwrap();
assert_eq!(roundtrip(&p), p);
@ -133,12 +135,78 @@ fn profile_cwd_template_is_camel_case() {
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"cwdTemplate\""), "json was {json}");
}
#[test]
fn profile_with_session_roundtrips_and_uses_camel_case() {
let session = SessionStrategy::new(Some("--session-id".into()), "--resume").unwrap();
let p = AgentProfile::new(
profid(1),
"Claude Code",
"claude",
vec![],
ContextInjection::stdin(),
None,
"{agentRunDir}",
Some(session),
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(
json.contains("\"session\":{\"assignFlag\":\"--session-id\",\"resumeFlag\":\"--resume\"}"),
"json was {json}"
);
assert_eq!(roundtrip(&p), p);
}
#[test]
fn profile_without_session_omits_the_field() {
let p = AgentProfile::new(
profid(1),
"n",
"c",
vec![],
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("session"), "json was {json}");
assert_eq!(roundtrip(&p), p);
}
#[test]
fn legacy_profile_without_session_deserialises_to_none() {
// A `profiles.json` produced before the `session` field existed.
let json = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "stdin" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise");
assert_eq!(p.session, None);
}
#[test]
fn session_assign_flag_omitted_when_none() {
let session = SessionStrategy::new(None, "--continue").unwrap();
let json = serde_json::to_string(&session).unwrap();
assert!(!json.contains("assignFlag"), "json was {json}");
assert!(json.contains("\"resumeFlag\":\"--continue\""), "json was {json}");
assert_eq!(roundtrip(&session), session);
}
// ---------------------------------------------------------------------------
// AgentTemplate
// ---------------------------------------------------------------------------
@ -299,6 +367,8 @@ fn layout_roundtrip() {
id: node(1),
session: Some(session(100)),
agent: None,
conversation_id: None,
agent_was_running: false,
}),
weight: 1.5,
},
@ -307,6 +377,8 @@ fn layout_roundtrip() {
id: node(2),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
}),
weight: 2.5,
},
@ -330,6 +402,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
id: node(1),
session: None,
agent: Some(AgentId::from_uuid(agent_uuid)),
conversation_id: None,
agent_was_running: false,
}));
let rt = roundtrip(&tree);
match rt.root {
@ -344,6 +418,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
id: node(2),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");

View File

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