Files
IdeA/crates/application/tests/agent_inspect.rs
Blomios 3ed0f6b45f 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>
2026-06-07 22:27:08 +02:00

360 lines
12 KiB
Rust

//! 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");
}