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