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