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