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:
@ -54,6 +54,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("CLAUDE.md is a valid convention target"),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
@ -65,6 +66,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("AGENTS.md is a valid convention target"),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
@ -76,6 +78,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("GEMINI.md is a valid convention target"),
|
||||
Some("gemini --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("gemini reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
@ -87,6 +90,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("aider flag template is non-empty"),
|
||||
Some("aider --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("aider reference profile is valid"),
|
||||
]
|
||||
|
||||
142
crates/application/src/agent/inspect.rs
Normal file
142
crates/application/src/agent/inspect.rs
Normal file
@ -0,0 +1,142 @@
|
||||
//! Best-effort conversation inspection use case (CONTEXT §T7, Part A).
|
||||
//!
|
||||
//! [`InspectConversation`] enriches a resume popup with the *last topic* and a
|
||||
//! *token indicator* read from a CLI's on-disk transcript. It is **best-effort
|
||||
//! by construction**: it routes the agent's [`AgentProfile`] to the first
|
||||
//! injected [`SessionInspector`] that [`supports`](SessionInspector::supports)
|
||||
//! it, and *any* miss — no inspector at all, an unsupported profile,
|
||||
//! [`InspectError::NotFound`], or [`InspectError::Read`] — degrades to **empty
|
||||
//! details** (`last_topic: None, token_count: None`) instead of an error. The
|
||||
//! resume must never be blocked by inspection.
|
||||
//!
|
||||
//! Extensibility (Open/Closed): adding a new inspectable CLI is *pushing one
|
||||
//! more adapter into the `Vec`* at the composition root — no change here.
|
||||
//!
|
||||
//! Like [`super::lifecycle::LaunchAgent`], it resolves the agent from the
|
||||
//! project manifest and its profile from the [`ProfileStore`], and inspects
|
||||
//! against the agent's **isolated run directory** (the very `cwd` the CLI was
|
||||
//! launched with) so the inspector points at the right transcript folder.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
|
||||
};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use super::lifecycle::agent_run_dir;
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`InspectConversation::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectConversationInput {
|
||||
/// The project owning the agent.
|
||||
pub project: Project,
|
||||
/// The agent whose conversation is being inspected.
|
||||
pub agent_id: AgentId,
|
||||
/// The persistent CLI conversation id recorded on the hosting cell.
|
||||
pub conversation_id: String,
|
||||
}
|
||||
|
||||
/// Output of [`InspectConversation::execute`]: the (possibly empty) best-effort
|
||||
/// details. Never an inspection error — a miss surfaces as empty fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectConversationOutput {
|
||||
/// Enriched, best-effort details (every field optional).
|
||||
pub details: ConversationDetails,
|
||||
}
|
||||
|
||||
/// Reads best-effort [`ConversationDetails`] for an agent's conversation.
|
||||
///
|
||||
/// Holds a (possibly empty) `Vec<Arc<dyn SessionInspector>>`: the agent's
|
||||
/// profile is routed to the first inspector that supports it. The use case still
|
||||
/// needs the context store (resolve the agent) and the profile store (resolve
|
||||
/// the profile), exactly like [`super::lifecycle::LaunchAgent`].
|
||||
pub struct InspectConversation {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
inspectors: Vec<Arc<dyn SessionInspector>>,
|
||||
}
|
||||
|
||||
impl InspectConversation {
|
||||
/// Builds the use case from its injected ports and the inspector list (which
|
||||
/// may be empty: that path simply yields empty details).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
inspectors: Vec<Arc<dyn SessionInspector>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
profiles,
|
||||
inspectors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the agent + profile + run dir, then asks the first supporting
|
||||
/// inspector for details. Returns **empty** details when no inspector
|
||||
/// matches or inspection misses (`NotFound`/`Read`); only genuine store
|
||||
/// failures (loading the manifest / profiles) surface as an error.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
|
||||
/// - [`AppError::Invalid`] if a persisted manifest entry / run dir is invalid,
|
||||
/// - [`AppError::Store`] on a manifest / profile store failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: InspectConversationInput,
|
||||
) -> Result<InspectConversationOutput, AppError> {
|
||||
// Resolve the agent from the manifest (for its profile + run dir).
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let profile = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
|
||||
|
||||
// The CLI runs with its isolated run dir as cwd; the inspector keys its
|
||||
// transcript lookup off that same cwd (same value LaunchAgent uses).
|
||||
let run_dir = agent_run_dir(&input.project.root, &agent.id)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// Route to the first inspector that supports this profile. No match ⇒
|
||||
// empty details (best-effort).
|
||||
let Some(inspector) = self.inspectors.iter().find(|i| i.supports(&profile)) else {
|
||||
return Ok(InspectConversationOutput {
|
||||
details: empty_details(),
|
||||
});
|
||||
};
|
||||
|
||||
// Any inspection miss (NotFound / Read) degrades to empty details — it
|
||||
// must never block a resume.
|
||||
let details = match inspector
|
||||
.details(&profile, &input.conversation_id, &run_dir)
|
||||
.await
|
||||
{
|
||||
Ok(details) => details,
|
||||
Err(InspectError::NotFound | InspectError::Read(_)) => empty_details(),
|
||||
};
|
||||
|
||||
Ok(InspectConversationOutput { details })
|
||||
}
|
||||
}
|
||||
|
||||
/// The empty, fully-degraded [`ConversationDetails`] (no topic, no tokens).
|
||||
fn empty_details() -> ConversationDetails {
|
||||
ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None,
|
||||
}
|
||||
}
|
||||
@ -14,12 +14,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
||||
PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||
ManifestEntry, MarkdownDoc, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind,
|
||||
SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -327,6 +329,12 @@ pub struct LaunchAgentInput {
|
||||
pub cols: u16,
|
||||
/// The layout leaf hosting the session (a fresh node when `None`).
|
||||
pub node_id: Option<NodeId>,
|
||||
/// The persistent CLI conversation id currently recorded on the hosting cell,
|
||||
/// if any. `Some` means a previous conversation exists and the launch should
|
||||
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
|
||||
/// when the profile supports it). The caller (which owns the layout) reads this
|
||||
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`LaunchAgent::execute`].
|
||||
@ -334,6 +342,13 @@ pub struct LaunchAgentInput {
|
||||
pub struct LaunchAgentOutput {
|
||||
/// The created agent terminal session.
|
||||
pub session: TerminalSession,
|
||||
/// The conversation id **assigned** by this launch, when the profile supports
|
||||
/// session assignment and the cell had none yet. The caller persists it on the
|
||||
/// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next
|
||||
/// open resumes instead of re-assigning. `None` when nothing new was assigned
|
||||
/// (resume of an existing id, degraded mode, or a profile without a session
|
||||
/// block) — the caller has nothing to persist.
|
||||
pub assigned_conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
@ -353,6 +368,7 @@ pub struct LaunchAgent {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -368,6 +384,7 @@ impl LaunchAgent {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
@ -378,6 +395,7 @@ impl LaunchAgent {
|
||||
skills,
|
||||
sessions,
|
||||
events,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
@ -459,6 +477,16 @@ impl LaunchAgent {
|
||||
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
|
||||
.await?;
|
||||
|
||||
// 3b. Seed the CLI's permission config in the run dir so the agent runs
|
||||
// with the project's full autonomy and never blocks on per-command
|
||||
// permission prompts. The agent's cwd is the run dir, so the CLI
|
||||
// writes/reads its permission file there; without a seed, the CLI
|
||||
// accumulates narrow per-command approvals and keeps prompting.
|
||||
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
|
||||
// + OS-sandbox model. Non-clobbering and best-effort.
|
||||
self.seed_cli_permissions(&profile, &run_dir, &input.project.root)
|
||||
.await?;
|
||||
|
||||
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
|
||||
// placeholder resolves against it.
|
||||
@ -466,9 +494,15 @@ impl LaunchAgent {
|
||||
content: content.clone(),
|
||||
relative_path: agent.context_path.clone(),
|
||||
};
|
||||
let mut spec = self
|
||||
.runtime
|
||||
.prepare_invocation(&profile, &prepared, &run_dir)?;
|
||||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||||
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
||||
// returned in the output so the caller persists it on the leaf.
|
||||
let (session_plan, assigned_conversation_id) =
|
||||
self.resolve_session_plan(&profile, input.conversation_id.clone());
|
||||
let mut spec =
|
||||
self.runtime
|
||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||
|
||||
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||
// the injection plan side effects *before* spawning.
|
||||
@ -503,7 +537,102 @@ impl LaunchAgent {
|
||||
session_id,
|
||||
});
|
||||
|
||||
Ok(LaunchAgentOutput { session })
|
||||
Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
||||
/// strategy and the cell's current `conversation_id` (T4).
|
||||
///
|
||||
/// Returns the plan *and* — when this launch mints a fresh id — that id, so the
|
||||
/// caller can persist it on the hosting leaf. The id is only generated for an
|
||||
/// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
|
||||
/// had no id yet); every other branch returns `None` (nothing to persist).
|
||||
///
|
||||
/// Branches:
|
||||
/// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
|
||||
/// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
|
||||
/// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
|
||||
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
||||
/// uses the bare resume flag only on later reopens);
|
||||
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
||||
fn resolve_session_plan(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
cell_conversation_id: Option<String>,
|
||||
) -> (SessionPlan, Option<String>) {
|
||||
// No session strategy at all: behave exactly as before.
|
||||
let Some(session) = &profile.session else {
|
||||
return (SessionPlan::None, None);
|
||||
};
|
||||
|
||||
// The cell already carries a conversation: resume it (no new id minted).
|
||||
if let Some(conversation_id) = cell_conversation_id {
|
||||
return (SessionPlan::Resume { conversation_id }, None);
|
||||
}
|
||||
|
||||
// Fresh cell. Only mint+assign an id when the profile can assign one;
|
||||
// otherwise (degraded mode) the first launch has nothing to resume.
|
||||
if session.assign_flag.is_some() {
|
||||
let conversation_id = self.ids.new_uuid().to_string();
|
||||
(
|
||||
SessionPlan::Assign {
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
Some(conversation_id),
|
||||
)
|
||||
} else {
|
||||
(SessionPlan::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds the agent's run dir with the CLI permission config matching its
|
||||
/// context-injection convention, so the agent inherits the project's autonomy
|
||||
/// instead of prompting per command.
|
||||
///
|
||||
/// Conditioned on the CLI convention (only Claude Code — convention file
|
||||
/// `CLAUDE.md` — has a known seed today); a no-op for any other CLI.
|
||||
/// Best-effort and **non-clobbering**: an existing file (possibly user-edited)
|
||||
/// is left untouched.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::FileSystem`] if the directory/file cannot be written.
|
||||
async fn seed_cli_permissions(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
project_root: &ProjectPath,
|
||||
) -> Result<(), AppError> {
|
||||
let is_claude = matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
if !is_claude {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let settings_path =
|
||||
RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str()));
|
||||
if self.fs.exists(&settings_path).await? {
|
||||
return Ok(());
|
||||
}
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str())))
|
||||
.await?;
|
||||
self.fs
|
||||
.write(
|
||||
&settings_path,
|
||||
claude_settings_seed(project_root.as_str()).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the context-injection plan that must happen *before* spawn:
|
||||
@ -561,10 +690,76 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
/// # Errors
|
||||
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
|
||||
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
|
||||
fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
|
||||
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
||||
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
||||
/// directory (the cwd is the run dir, the agent works on the root above it), while
|
||||
/// keeping destructive/out-of-project commands denied. `project_root` is embedded
|
||||
/// verbatim; it is JSON-escaped to stay valid for unusual paths.
|
||||
///
|
||||
/// Pure (no I/O), so it is unit-testable in isolation.
|
||||
#[must_use]
|
||||
fn claude_settings_seed(project_root: &str) -> String {
|
||||
let root = json_escape(project_root);
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "bypassPermissions",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)"
|
||||
]
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path in the settings
|
||||
/// seed (handles the characters that actually occur in paths: backslash, quote,
|
||||
/// and control chars).
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the agent's persona `.md`, then the bodies
|
||||
@ -700,4 +895,38 @@ mod tests {
|
||||
assert!(doc.contains("## refactor"));
|
||||
assert!(doc.contains("## review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
||||
let json = claude_settings_seed("/home/me/proj");
|
||||
|
||||
// Full autonomy.
|
||||
assert!(json.contains("\"defaultMode\": \"bypassPermissions\""));
|
||||
assert!(json.contains("\"Bash\""));
|
||||
// Project root granted as an additional working directory.
|
||||
assert!(json.contains("\"/home/me/proj\""));
|
||||
// Destructive guardrails preserved.
|
||||
assert!(json.contains("Bash(sudo *)"));
|
||||
assert!(json.contains("Bash(rm -rf /)"));
|
||||
assert!(json.contains("Bash(mkfs*)"));
|
||||
// Valid JSON.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
||||
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
"/home/me/proj"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_escapes_paths_for_valid_json() {
|
||||
// A path with a backslash and a quote must not break the JSON.
|
||||
let json = claude_settings_seed(r#"/weird\path"x"#);
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("seed with odd path is valid JSON");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
r#"/weird\path"x"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,16 @@
|
||||
//! ports. Launching an agent (PTY + injection) is L6.
|
||||
|
||||
mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use inspect::{
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
};
|
||||
pub use lifecycle::{
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
|
||||
@ -21,9 +21,13 @@
|
||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||
|
||||
mod management;
|
||||
mod snapshot;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
|
||||
113
crates/application/src/layout/snapshot.rs
Normal file
113
crates/application/src/layout/snapshot.rs
Normal file
@ -0,0 +1,113 @@
|
||||
//! [`SnapshotRunningAgents`] — freeze, at close time, which agent cells still
|
||||
//! held a live PTY (feature: "conversation resume", task T5).
|
||||
//!
|
||||
//! When the IDE (or a single project) closes, every live PTY is about to be
|
||||
//! killed. *Before* that happens, we record on each agent-bearing leaf whether
|
||||
//! its agent process was still running (`agent_was_running = true`) or had
|
||||
//! already exited / never launched (`false`). On reopen, that flag is what tells
|
||||
//! the resume popup "this conversation was still in progress" vs "it was closed".
|
||||
//!
|
||||
//! The decision is **universal**: it is derived purely from the process
|
||||
//! lifecycle (the live-session registry), never from parsing CLI output. The use
|
||||
//! case itself is a thin orchestrator over:
|
||||
//! - the persisted layouts store ([`super::store`]),
|
||||
//! - the pure domain operation [`domain::LayoutTree::set_agent_running`],
|
||||
//! - a [`LiveAgentRegistry`] liveness query.
|
||||
//!
|
||||
//! **Ordering contract:** this snapshot reads the registry *as it is at call
|
||||
//! time*. The composition root (app-tauri) is responsible for calling it
|
||||
//! **before** the global PTY kill; if the kill ran first, every agent would look
|
||||
//! "closed". See `app-tauri`'s `CloseRequested` hook and `close.rs` callers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, ProjectStore};
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
use super::store::{persist_doc, resolve_doc};
|
||||
|
||||
/// Input for [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotRunningAgentsInput {
|
||||
/// The project whose layouts must be frozen.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SnapshotRunningAgentsOutput {
|
||||
/// Number of agent-bearing leaves that were found running at snapshot time.
|
||||
pub running: usize,
|
||||
/// Number of agent-bearing leaves that were found stopped at snapshot time.
|
||||
pub stopped: usize,
|
||||
}
|
||||
|
||||
/// Freezes the `agent_was_running` flag on every agent leaf of a project's
|
||||
/// layouts, from the live-session registry, then persists the layouts.
|
||||
pub struct SnapshotRunningAgents {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
}
|
||||
|
||||
impl SnapshotRunningAgents {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
) -> Self {
|
||||
Self { store, fs, live }
|
||||
}
|
||||
|
||||
/// Executes the snapshot for one project.
|
||||
///
|
||||
/// Walks every named layout of the project; for each agent-bearing leaf it
|
||||
/// applies [`domain::LayoutTree::set_agent_running`] with the agent's *current*
|
||||
/// liveness, then persists the whole layouts store. A project with no
|
||||
/// agent leaf is a no-op (the doc is still resolved/healed, never written if
|
||||
/// unchanged).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the project is unknown,
|
||||
/// - [`AppError::FileSystem`] on persistence failure,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SnapshotRunningAgentsInput,
|
||||
) -> Result<SnapshotRunningAgentsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let mut out = SnapshotRunningAgentsOutput::default();
|
||||
let mut changed = false;
|
||||
|
||||
for named in &mut doc.layouts {
|
||||
for (leaf_id, agent_id) in named.tree.agent_leaves() {
|
||||
let running = self.live.is_agent_live(&agent_id);
|
||||
if running {
|
||||
out.running += 1;
|
||||
} else {
|
||||
out.stopped += 1;
|
||||
}
|
||||
// Pure op: only NodeNotFound is possible, which cannot happen
|
||||
// since `leaf_id` came from this very tree.
|
||||
named.tree = named
|
||||
.tree
|
||||
.set_agent_running(leaf_id, running)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@ -103,6 +103,8 @@ pub fn default_tree() -> LayoutTree {
|
||||
id: NodeId::new_random(),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,17 @@ pub enum LayoutOperation {
|
||||
/// Agent to associate, or `None` to clear.
|
||||
agent: Option<AgentId>,
|
||||
},
|
||||
/// Record (or clear) the persistent CLI conversation id on a leaf (T4b).
|
||||
///
|
||||
/// Persisting the id minted at first launch is what makes session resume
|
||||
/// effective: on the next open, the leaf carries the id and the launch
|
||||
/// resumes the conversation instead of assigning a new one.
|
||||
SetCellConversation {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Conversation id to record, or `None` to clear.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperation {
|
||||
@ -94,6 +105,8 @@ impl LayoutOperation {
|
||||
id: *new_leaf,
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
},
|
||||
*container,
|
||||
),
|
||||
@ -105,6 +118,10 @@ impl LayoutOperation {
|
||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||
Self::SetCellConversation {
|
||||
target,
|
||||
conversation_id,
|
||||
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
||||
};
|
||||
result.map_err(map_layout_err)
|
||||
}
|
||||
|
||||
@ -28,7 +28,8 @@ pub use agent::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
|
||||
@ -49,7 +50,8 @@ pub use layout::{
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents,
|
||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
@ -71,6 +73,7 @@ pub use skill::{
|
||||
UpdateSkillOutput,
|
||||
};
|
||||
pub use terminal::{
|
||||
LiveAgentRegistry,
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||
WriteToTerminalInput,
|
||||
|
||||
@ -144,6 +144,7 @@ impl OrchestratorService {
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@ -294,6 +295,7 @@ mod tests {
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
mod registry;
|
||||
mod usecases;
|
||||
|
||||
pub use registry::TerminalSessions;
|
||||
pub use registry::{LiveAgentRegistry, TerminalSessions};
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
|
||||
@ -19,12 +19,30 @@ struct Entry {
|
||||
session: TerminalSession,
|
||||
}
|
||||
|
||||
/// Read-only liveness query over the agents that currently own a live PTY.
|
||||
///
|
||||
/// Abstracted as a trait so use cases that only need to ask "is this agent
|
||||
/// running right now?" (e.g. the close-time snapshot of running agents) depend
|
||||
/// on the *capability*, not on the whole [`TerminalSessions`] registry — and can
|
||||
/// be tested against a trivial fake. [`TerminalSessions`] is the production
|
||||
/// implementation.
|
||||
pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// Whether `agent_id` currently has a live session in the registry.
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool;
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalSessions {
|
||||
entries: Mutex<HashMap<SessionId, Entry>>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for TerminalSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
/// Creates an empty registry.
|
||||
#[must_use]
|
||||
|
||||
359
crates/application/tests/agent_inspect.rs
Normal file
359
crates/application/tests/agent_inspect.rs
Normal file
@ -0,0 +1,359 @@
|
||||
//! T7 tests for the [`InspectConversation`] use case (best-effort conversation
|
||||
//! inspection feeding the resume popup).
|
||||
//!
|
||||
//! Each port is faked in-memory:
|
||||
//! - [`FakeContexts`] — an [`AgentContextStore`] holding a manifest seeded with
|
||||
//! one agent,
|
||||
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
||||
//! - [`FakeInspector`] — a [`SessionInspector`] whose `supports`/`details` are
|
||||
//! configurable, so we can exercise: a supporting inspector returning details,
|
||||
//! one returning `NotFound`, and the empty-`Vec` (no inspector) path.
|
||||
//!
|
||||
//! The contract under test (CONTEXT §T7 Part A): inspection is **best-effort** —
|
||||
//! any miss (no inspector, unsupported profile, `NotFound`/`Read`) degrades to
|
||||
//! empty details, never an error; only a genuine store failure errors.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{InspectConversation, InspectConversationInput};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore) — only load_manifest is exercised here
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
||||
|
||||
impl FakeContexts {
|
||||
fn with_agent(agent: &Agent) -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry::from_agent(agent)],
|
||||
})))
|
||||
}
|
||||
fn empty() -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
Ok(MarkdownDoc::new(""))
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
_md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.0.lock().unwrap() = manifest.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeProfiles (ProfileStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeInspector (SessionInspector) — configurable support + result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What the fake inspector returns from `details`.
|
||||
#[derive(Clone)]
|
||||
enum InspectResult {
|
||||
Ok(ConversationDetails),
|
||||
Err(InspectError),
|
||||
}
|
||||
|
||||
/// Shared probe recording the `(conversation_id, cwd)` an inspection was asked
|
||||
/// for (so a test can assert the run dir was routed, not the project root).
|
||||
type SeenProbe = Arc<Mutex<Option<(String, String)>>>;
|
||||
|
||||
struct FakeInspector {
|
||||
supports: bool,
|
||||
result: InspectResult,
|
||||
seen: SeenProbe,
|
||||
}
|
||||
|
||||
impl FakeInspector {
|
||||
fn new(supports: bool, result: InspectResult) -> (Arc<Self>, SeenProbe) {
|
||||
let seen = Arc::new(Mutex::new(None));
|
||||
let me = Arc::new(Self {
|
||||
supports,
|
||||
result,
|
||||
seen: Arc::clone(&seen),
|
||||
});
|
||||
(me, seen)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionInspector for FakeInspector {
|
||||
fn supports(&self, _profile: &AgentProfile) -> bool {
|
||||
self.supports
|
||||
}
|
||||
async fn details(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
conversation_id: &str,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<ConversationDetails, InspectError> {
|
||||
*self.seen.lock().unwrap() = Some((conversation_id.to_owned(), cwd.as_str().to_owned()));
|
||||
match &self.result {
|
||||
InspectResult::Ok(d) => Ok(d.clone()),
|
||||
InspectResult::Err(e) => Err(e.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::ConventionFile {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
},
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
|
||||
Agent::new(id, "Bob", "agents/bob.md", profile_id, AgentOrigin::Scratch, false).unwrap()
|
||||
}
|
||||
|
||||
fn input(agent_id: AgentId) -> InspectConversationInput {
|
||||
InspectConversationInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
conversation_id: "conv-123".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) supporting inspector → details propagated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn supporting_inspector_propagates_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: Some("refactor the parser".to_owned()),
|
||||
token_count: Some(4242),
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic.as_deref(), Some("refactor the parser"));
|
||||
assert_eq!(out.details.token_count, Some(4242));
|
||||
|
||||
// Routed the conversation id and the agent's isolated run dir (not the root).
|
||||
let (conv, cwd) = seen.lock().unwrap().clone().expect("inspector was called");
|
||||
assert_eq!(conv, "conv-123");
|
||||
assert_eq!(cwd, format!("/home/me/proj/.ideai/run/{aid}"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) inspector returns NotFound → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_degrades_to_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, _seen) = FakeInspector::new(true, InspectResult::Err(InspectError::NotFound));
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b') inspector returns Read error → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_error_degrades_to_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, _seen) =
|
||||
FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned())));
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details, ConversationDetails { last_topic: None, token_count: None });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) empty Vec (no inspector) → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_inspector_yields_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c') an inspector that does NOT support the profile is skipped → empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_inspector_is_skipped() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, seen) = FakeInspector::new(
|
||||
false, // does not support
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: Some("should never surface".to_owned()),
|
||||
token_count: Some(1),
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
// details() must not have been called.
|
||||
assert!(seen.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Genuine resolution failures still error (unknown agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_agent_errors() {
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(99));
|
||||
let (inspector, _seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::empty()),
|
||||
Arc::new(FakeProfiles(Arc::new(Vec::new()))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let err = uc.execute(input(aid)).await.unwrap_err();
|
||||
// Should be a NOT_FOUND-class error, not a panic / empty success.
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
}
|
||||
@ -26,9 +26,10 @@ 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::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
@ -228,11 +229,22 @@ impl SkillStore for FakeSkills {
|
||||
struct FakeRuntime {
|
||||
trace: Trace,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
/// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests
|
||||
/// can assert the launch resolved the right Assign/Resume/None intention (T4).
|
||||
last_session: Arc<Mutex<Option<SessionPlan>>>,
|
||||
}
|
||||
|
||||
impl FakeRuntime {
|
||||
fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> Self {
|
||||
Self { trace, plan }
|
||||
Self {
|
||||
trace,
|
||||
plan,
|
||||
last_session: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
/// Shared handle to inspect the captured session plan after a launch.
|
||||
fn session_probe(&self) -> Arc<Mutex<Option<SessionPlan>>> {
|
||||
Arc::clone(&self.last_session)
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,7 +257,9 @@ impl AgentRuntime for FakeRuntime {
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
*self.last_session.lock().unwrap() = Some(session.clone());
|
||||
self.trace.lock().unwrap().push("prepare".to_owned());
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
@ -282,6 +296,28 @@ impl FakeFs {
|
||||
fn created_dirs(&self) -> Vec<String> {
|
||||
self.created_dirs.lock().unwrap().clone()
|
||||
}
|
||||
/// Convention-file / context writes only (excludes the Claude permission seed),
|
||||
/// so injection assertions stay focused on the agent's `.md`.
|
||||
fn context_writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes()
|
||||
.into_iter()
|
||||
.filter(|(p, _)| !p.ends_with("/.claude/settings.local.json"))
|
||||
.collect()
|
||||
}
|
||||
/// Only the Claude permission seed writes (`.claude/settings.local.json`).
|
||||
fn seed_writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes()
|
||||
.into_iter()
|
||||
.filter(|(p, _)| p.ends_with("/.claude/settings.local.json"))
|
||||
.collect()
|
||||
}
|
||||
/// Created run dirs excluding the seed's `.claude` subdir.
|
||||
fn created_run_dirs(&self) -> Vec<String> {
|
||||
self.created_dirs()
|
||||
.into_iter()
|
||||
.filter(|p| !p.ends_with("/.claude"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -443,6 +479,7 @@ fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
||||
injection,
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@ -602,15 +639,26 @@ type LaunchFixture = (
|
||||
SpyBus,
|
||||
Arc<TerminalSessions>,
|
||||
Trace,
|
||||
Arc<Mutex<Option<SessionPlan>>>,
|
||||
);
|
||||
|
||||
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
|
||||
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
launch_fixture_with_profile(profile(pid(9), injection), plan)
|
||||
}
|
||||
|
||||
/// Like [`launch_fixture`] but takes a fully-built profile (so session-strategy
|
||||
/// variants can be exercised). The seeded agent uses the profile's id.
|
||||
fn launch_fixture_with_profile(
|
||||
profile: AgentProfile,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
let runtime = FakeRuntime::new(Arc::clone(&tr), plan);
|
||||
let session_probe = runtime.session_probe();
|
||||
let fs = FakeFs::new(Arc::clone(&tr));
|
||||
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
@ -624,8 +672,9 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
|
||||
Arc::new(FakeSkills::default()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
);
|
||||
(launch, agent, fs, pty, bus, sessions, tr)
|
||||
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
|
||||
}
|
||||
|
||||
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
||||
@ -635,13 +684,14 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
node_id: None,
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
// conventionFile strategy → an fs.write must happen between prepare and spawn.
|
||||
let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture(
|
||||
let (launch, agent, fs, pty, bus, sessions, tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
@ -650,11 +700,17 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
// Ordering contract.
|
||||
// Ordering contract. The Claude permission seed is written first (right after
|
||||
// the run dir is created), then prepare → injection (convention file) → spawn.
|
||||
assert_eq!(
|
||||
*tr.lock().unwrap(),
|
||||
vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()],
|
||||
"prepare → injection → spawn"
|
||||
vec![
|
||||
"fs.write".to_owned(),
|
||||
"prepare".to_owned(),
|
||||
"fs.write".to_owned(),
|
||||
"spawn".to_owned()
|
||||
],
|
||||
"seed → prepare → injection → spawn"
|
||||
);
|
||||
|
||||
// The conventionFile was written inside the agent's isolated run directory
|
||||
@ -662,7 +718,7 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
// is the *composed* document: an absolute project-root header followed by the
|
||||
// agent persona `.md`.
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let writes = fs.writes();
|
||||
let writes = fs.context_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
|
||||
let written = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||
@ -675,8 +731,20 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
"convention file must carry the agent persona, got: {written}"
|
||||
);
|
||||
|
||||
// The run directory was created (via the FileSystem port) before spawn.
|
||||
assert_eq!(fs.created_dirs(), vec![run_dir.clone()]);
|
||||
// Bug #5: a Claude permission seed was written into the run dir so the agent
|
||||
// runs with the project's autonomy instead of prompting per command.
|
||||
let seeds = fs.seed_writes();
|
||||
assert_eq!(seeds.len(), 1);
|
||||
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
|
||||
let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
|
||||
assert!(seed.contains("bypassPermissions"), "seed grants autonomy: {seed}");
|
||||
assert!(seed.contains("/home/me/proj"), "seed grants the project root");
|
||||
|
||||
// The run directory (and the seed's `.claude` subdir) were created before spawn.
|
||||
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
|
||||
assert!(fs
|
||||
.created_dirs()
|
||||
.contains(&format!("{run_dir}/.claude")));
|
||||
|
||||
// Spawn happened at the isolated run dir with the profile command.
|
||||
let spawns = pty.spawns();
|
||||
@ -743,6 +811,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
Arc::new(FakeSkills::default()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent_a.id)).await.unwrap();
|
||||
@ -752,8 +821,8 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
|
||||
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
|
||||
|
||||
// Two distinct run dirs were created.
|
||||
assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]);
|
||||
// Two distinct run dirs were created (ignoring each seed's `.claude` subdir).
|
||||
assert_eq!(fs.created_run_dirs(), vec![dir_a.clone(), dir_b.clone()]);
|
||||
|
||||
// Two spawns at two distinct cwd — the core anti-collision guarantee.
|
||||
let spawns = pty.spawns();
|
||||
@ -763,7 +832,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
assert_ne!(spawns[0].cwd, spawns[1].cwd);
|
||||
|
||||
// Two convention files, each inside its own run dir (no shared root file).
|
||||
let writes = fs.writes();
|
||||
let writes = fs.context_writes();
|
||||
assert_eq!(writes.len(), 2);
|
||||
assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
|
||||
assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
|
||||
@ -815,11 +884,12 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||
Arc::new(skills),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let writes = fs.writes();
|
||||
let writes = fs.context_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||
assert!(doc.contains("# persona"), "persona present: {doc}");
|
||||
@ -861,16 +931,42 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||
Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
||||
let doc = String::from_utf8(fs.writes()[0].1.clone()).unwrap();
|
||||
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
|
||||
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_non_claude_convention_does_not_seed_permissions() {
|
||||
// A CLI whose convention file is NOT CLAUDE.md (e.g. Codex → AGENTS.md) must
|
||||
// get its convention file but NO Claude permission seed (Bug #5 is per-CLI).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
// The convention file was written, but no permission seed.
|
||||
assert_eq!(fs.context_writes().len(), 1);
|
||||
assert!(
|
||||
fs.seed_writes().is_empty(),
|
||||
"no Claude seed for a non-Claude CLI"
|
||||
);
|
||||
assert!(
|
||||
!fs.created_dirs().iter().any(|p| p.ends_with("/.claude")),
|
||||
"no .claude dir created for a non-Claude CLI"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
||||
let (launch, agent, fs, pty, _bus, _sessions, tr, _session) =
|
||||
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
@ -886,7 +982,7 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_unknown_agent_is_not_found() {
|
||||
let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture(
|
||||
let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::stdin(),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
@ -912,9 +1008,109 @@ async fn launch_unknown_profile_is_not_found() {
|
||||
Arc::new(FakeSkills::default()),
|
||||
Arc::new(TerminalSessions::new()),
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
);
|
||||
|
||||
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaunchAgent — session resume (T4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds a stdin profile carrying a [`SessionStrategy`] (assign + resume flags).
|
||||
fn profile_with_session(
|
||||
id: ProfileId,
|
||||
assign_flag: Option<&str>,
|
||||
resume_flag: &str,
|
||||
) -> AgentProfile {
|
||||
let session =
|
||||
SessionStrategy::new(assign_flag.map(str::to_owned), resume_flag.to_owned()).unwrap();
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
Some(session),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id() {
|
||||
// Fresh cell (conversation_id = None), profile WITH an assign_flag.
|
||||
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
|
||||
profile_with_session(pid(9), Some("--session-id"), "--resume"),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
// SeqIds yields Uuid::from_u128(1) on its first call.
|
||||
let expected = Uuid::from_u128(1).to_string();
|
||||
|
||||
// The use case exposes the assigned id (caller persists it on the leaf).
|
||||
assert_eq!(out.assigned_conversation_id.as_deref(), Some(expected.as_str()));
|
||||
|
||||
// The runtime received an Assign plan carrying exactly that id.
|
||||
assert_eq!(
|
||||
*session.lock().unwrap(),
|
||||
Some(SessionPlan::Assign { conversation_id: expected }),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_reopen_with_existing_conversation_id_resumes_without_new_id() {
|
||||
// The cell already carries a conversation id ⇒ Resume, no new id minted.
|
||||
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
|
||||
profile_with_session(pid(9), Some("--session-id"), "--resume"),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some("conv-existing".to_owned());
|
||||
let out = launch.execute(input).await.expect("launch");
|
||||
|
||||
// Nothing newly assigned (the id pre-existed): caller has nothing to persist.
|
||||
assert_eq!(out.assigned_conversation_id, None);
|
||||
|
||||
// Resume plan with the existing id; SeqIds was never consulted.
|
||||
assert_eq!(
|
||||
*session.lock().unwrap(),
|
||||
Some(SessionPlan::Resume {
|
||||
conversation_id: "conv-existing".to_owned()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_profile_without_session_block_passes_none() {
|
||||
// Non-regression: a profile with no session block behaves exactly as before.
|
||||
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) =
|
||||
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert_eq!(out.assigned_conversation_id, None);
|
||||
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
|
||||
// Profile HAS a session block but NO assign_flag (degraded): a first launch
|
||||
// (no cell id) must NOT mint an id and must pass SessionPlan::None.
|
||||
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile(
|
||||
profile_with_session(pid(9), None, "--continue"),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert_eq!(out.assigned_conversation_id, None, "degraded mode mints no id");
|
||||
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
|
||||
}
|
||||
|
||||
@ -191,6 +191,8 @@ fn single_leaf(node_id: NodeId) -> LayoutTree {
|
||||
id: node_id,
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -638,6 +640,77 @@ async fn mutate_set_cell_agent_missing_leaf_is_not_found() {
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetCellConversation (T4b — persist the assigned CLI conversation id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_cell_conversation_persists_id_on_leaf() {
|
||||
let env = mut_env(pid(52)).await;
|
||||
// Record a conversation id on the single root leaf (nid(1)).
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellConversation {
|
||||
target: nid(1),
|
||||
conversation_id: Some("conv-42".to_owned()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set_cell_conversation records the id");
|
||||
|
||||
// The id must survive in the persisted JSON, so the next open resumes.
|
||||
let tree_json = active_tree_json(&env.fs);
|
||||
assert_eq!(
|
||||
tree_json["root"]["node"]["conversationId"], "conv-42",
|
||||
"conversation id must be persisted on the leaf"
|
||||
);
|
||||
|
||||
// Now clear it.
|
||||
let out = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellConversation {
|
||||
target: nid(1),
|
||||
conversation_id: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set_cell_conversation clears the id");
|
||||
|
||||
match &out.layout.root {
|
||||
LayoutNode::Leaf(l) => assert_eq!(l.conversation_id, None, "id must be cleared"),
|
||||
_ => panic!("expected leaf root"),
|
||||
}
|
||||
assert!(
|
||||
active_tree_json(&env.fs)["root"]["node"]
|
||||
.get("conversationId")
|
||||
.is_none(),
|
||||
"cleared id must not be serialised"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
|
||||
let env = mut_env(pid(53)).await;
|
||||
let err = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetCellConversation {
|
||||
target: nid(404),
|
||||
conversation_id: Some("x".to_owned()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect_err("unknown node rejected");
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named-layout management (#4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -22,7 +22,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};
|
||||
@ -231,6 +232,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
@ -372,6 +374,7 @@ fn claude_profile() -> AgentProfile {
|
||||
ContextInjection::stdin(),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@ -410,6 +413,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
|
||||
@ -13,7 +13,7 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{
|
||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SpawnSpec, StoreError,
|
||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
@ -103,6 +103,7 @@ impl AgentRuntime for StubRuntime {
|
||||
_profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
unreachable!("not used in these tests")
|
||||
}
|
||||
@ -117,6 +118,7 @@ fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
ContextInjection::stdin(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
389
crates/application/tests/snapshot_running_agents.rs
Normal file
389
crates/application/tests/snapshot_running_agents.rs
Normal file
@ -0,0 +1,389 @@
|
||||
//! T5 tests for [`SnapshotRunningAgents`].
|
||||
//!
|
||||
//! At close time, before the global PTY kill, the use case must freeze on each
|
||||
//! agent-bearing leaf whether that agent's PTY was still live
|
||||
//! (`agent_was_running`). Liveness is derived purely from the live-session
|
||||
//! registry (a [`LiveAgentRegistry`]), never from CLI parsing.
|
||||
//!
|
||||
//! Every port is faked in-memory. We assert the persisted `layouts.json` flags
|
||||
//! and the ordering guarantee (snapshot reads liveness BEFORE the kill).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError};
|
||||
use domain::{
|
||||
AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId,
|
||||
ProjectPath, RemoteRef, SplitContainer, WeightedChild,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{LiveAgentRegistry, SnapshotRunningAgents, SnapshotRunningAgentsInput};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFsInner {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.lock().unwrap().files.get(path).cloned()
|
||||
}
|
||||
fn put(&self, path: &str, data: &[u8]) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.to_owned(), data.to_vec());
|
||||
}
|
||||
fn writes(&self) -> usize {
|
||||
self.0.lock().unwrap().files.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.files
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
let inner = self.0.lock().unwrap();
|
||||
Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().projects.push(project.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// A controllable liveness registry: an agent is "live" iff it is in the set.
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>);
|
||||
|
||||
impl FakeLive {
|
||||
fn with(agents: &[AgentId]) -> Self {
|
||||
Self(Arc::new(Mutex::new(agents.iter().copied().collect())))
|
||||
}
|
||||
/// Simulates a PTY kill: forget every live agent.
|
||||
fn kill_all(&self) {
|
||||
self.0.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for FakeLive {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.0.lock().unwrap().contains(agent_id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROOT: &str = "/home/me/proj";
|
||||
const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json";
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
|
||||
LeafCell {
|
||||
id: node,
|
||||
session: None,
|
||||
agent,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_project(store: &FakeStore, id: ProjectId) {
|
||||
let project =
|
||||
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
}
|
||||
|
||||
/// Seeds a valid `layouts.json` with one active layout holding `tree`.
|
||||
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
||||
let doc = serde_json::json!({
|
||||
"version": 1,
|
||||
"activeId": id.to_string(),
|
||||
"layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ],
|
||||
});
|
||||
fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap());
|
||||
}
|
||||
|
||||
fn doc_json(fs: &FakeFs) -> serde_json::Value {
|
||||
serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap()
|
||||
}
|
||||
|
||||
/// Walks the active layout tree and returns `agent_was_running` for the leaf
|
||||
/// `node`, or `None` if absent.
|
||||
fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
||||
let doc = doc_json(fs);
|
||||
let tree: LayoutTree =
|
||||
serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable");
|
||||
fn find(node: &LayoutNode, target: NodeId) -> Option<bool> {
|
||||
match node {
|
||||
LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running),
|
||||
LayoutNode::Leaf(_) => None,
|
||||
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
|
||||
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
|
||||
}
|
||||
}
|
||||
find(&tree.root, node)
|
||||
}
|
||||
|
||||
fn make_use_case(
|
||||
store: &FakeStore,
|
||||
fs: &FakeFs,
|
||||
live: &FakeLive,
|
||||
) -> SnapshotRunningAgents {
|
||||
SnapshotRunningAgents::new(
|
||||
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
|
||||
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
|
||||
Arc::new(live.clone()) as Arc<dyn LiveAgentRegistry>,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A live agent's leaf is persisted with `agent_was_running = true`.
|
||||
#[tokio::test]
|
||||
async fn live_agent_is_marked_running() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
|
||||
let live = FakeLive::with(&[agent]);
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
|
||||
let out = uc
|
||||
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.running, 1);
|
||||
assert_eq!(out.stopped, 0);
|
||||
assert_eq!(was_running(&fs, leaf), Some(true));
|
||||
}
|
||||
|
||||
/// An agent whose PTY has already exited is persisted with `false`.
|
||||
#[tokio::test]
|
||||
async fn stopped_agent_is_marked_not_running() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
|
||||
// Registry is empty: the agent has no live session.
|
||||
let live = FakeLive::default();
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
|
||||
let out = uc
|
||||
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.running, 0);
|
||||
assert_eq!(out.stopped, 1);
|
||||
assert_eq!(was_running(&fs, leaf), Some(false));
|
||||
}
|
||||
|
||||
/// Mixed tree (split with one live agent, one stopped agent, one plain leaf):
|
||||
/// each agent leaf gets the right flag; the non-agent leaf is untouched.
|
||||
#[tokio::test]
|
||||
async fn mixed_tree_flags_each_agent_independently() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let live_leaf = nid(10);
|
||||
let dead_leaf = nid(11);
|
||||
let plain_leaf = nid(12);
|
||||
let live_agent = aid(100);
|
||||
let dead_agent = aid(101);
|
||||
|
||||
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
|
||||
id: nid(1),
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(live_leaf, Some(live_agent))),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(dead_leaf, Some(dead_agent))),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(plain_leaf, None)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
seed_layouts(&fs, lid(1), &tree);
|
||||
|
||||
let live = FakeLive::with(&[live_agent]);
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
|
||||
let out = uc
|
||||
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.running, 1);
|
||||
assert_eq!(out.stopped, 1);
|
||||
assert_eq!(was_running(&fs, live_leaf), Some(true));
|
||||
assert_eq!(was_running(&fs, dead_leaf), Some(false));
|
||||
assert_eq!(was_running(&fs, plain_leaf), Some(false)); // default, untouched
|
||||
}
|
||||
|
||||
/// Ordering guarantee: the snapshot reads liveness BEFORE the kill. Running the
|
||||
/// snapshot on the live registry persists `true`; running it AFTER `kill_all`
|
||||
/// (simulating a kill-then-snapshot mistake) would persist `false`.
|
||||
#[tokio::test]
|
||||
async fn snapshot_reads_liveness_before_kill() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
|
||||
let live = FakeLive::with(&[agent]);
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
|
||||
// Correct order (composition root contract): snapshot THEN kill.
|
||||
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
live.kill_all();
|
||||
assert_eq!(
|
||||
was_running(&fs, leaf),
|
||||
Some(true),
|
||||
"snapshot taken before kill must record running"
|
||||
);
|
||||
|
||||
// Re-seed and demonstrate the opposite order yields `false` — proving the
|
||||
// flag is sensitive to registry state at call time (hence order matters).
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
was_running(&fs, leaf),
|
||||
Some(false),
|
||||
"snapshot taken after kill records not-running"
|
||||
);
|
||||
}
|
||||
|
||||
/// A layout with no agent leaf is a no-op: nothing is written.
|
||||
#[tokio::test]
|
||||
async fn no_agent_leaf_is_noop() {
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
register_project(&store, pid(1)).await;
|
||||
|
||||
let leaf = nid(10);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, None)));
|
||||
let writes_before = fs.writes();
|
||||
|
||||
let live = FakeLive::default();
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
|
||||
let out = uc
|
||||
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.running, 0);
|
||||
assert_eq!(out.stopped, 0);
|
||||
// No agent leaf → no persistence triggered (file count unchanged).
|
||||
assert_eq!(fs.writes(), writes_before);
|
||||
assert_eq!(was_running(&fs, leaf), Some(false));
|
||||
}
|
||||
@ -61,6 +61,8 @@ fn tab(n: u128) -> Tab {
|
||||
id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user