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:
364
crates/infrastructure/src/inspector/claude.rs
Normal file
364
crates/infrastructure/src/inspector/claude.rs
Normal file
@ -0,0 +1,364 @@
|
||||
//! [`ClaudeTranscriptInspector`] — a best-effort [`SessionInspector`] adapter
|
||||
//! (CONTEXT §T6) for the **Claude Code** CLI.
|
||||
//!
|
||||
//! Claude Code records each conversation as a JSONL transcript under the user's
|
||||
//! home directory:
|
||||
//!
|
||||
//! ```text
|
||||
//! <home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl
|
||||
//! ```
|
||||
//!
|
||||
//! where `<encoded-cwd>` is the agent's working directory with its path
|
||||
//! separators flattened to `-` (see [`encode_cwd`]). Each line of the `.jsonl`
|
||||
//! is one JSON message object.
|
||||
//!
|
||||
//! This adapter is the **only** place that knows the Claude transcript shape:
|
||||
//! it reads the file through the injected [`FileSystem`] port, extracts a
|
||||
//! best-effort `last_topic` (the last `user` message text) and `token_count`
|
||||
//! (the cumulative `usage` input+output tokens of `assistant` messages), and
|
||||
//! returns the CLI-agnostic [`ConversationDetails`]. No Claude-specific type
|
||||
//! ever leaves this module. Anything it cannot parse is skipped, never fatal.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use domain::ports::{
|
||||
ConversationDetails, FileSystem, FsError, InspectError, RemotePath, SessionInspector,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// Max byte length of the extracted `last_topic` before it is truncated.
|
||||
///
|
||||
/// Kept small: the topic only labels a resume popup, it is not the message.
|
||||
const TOPIC_MAX_LEN: usize = 120;
|
||||
|
||||
/// Reads Claude Code conversation transcripts.
|
||||
///
|
||||
/// Composes a [`FileSystem`] port (so it stays Tauri- and OS-agnostic and is
|
||||
/// trivially testable) plus the base directory that stands in for the user's
|
||||
/// home (`<home>/.claude/projects/`). The composition root passes the real
|
||||
/// `$HOME`; tests pass a temp directory via [`with_base_dir`](Self::with_base_dir).
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeTranscriptInspector {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
/// Directory that plays the role of the user's home directory; the adapter
|
||||
/// looks under `<home>/.claude/projects/`.
|
||||
home_dir: String,
|
||||
}
|
||||
|
||||
impl ClaudeTranscriptInspector {
|
||||
/// Builds the inspector from an injected [`FileSystem`] and the user's home
|
||||
/// directory (resolved by the composition root, e.g. from `$HOME`).
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
home_dir: home_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test/seam constructor: identical to [`new`](Self::new) but named to make
|
||||
/// the injected base directory explicit at call sites (tests point it at a
|
||||
/// temp `~/.claude/projects/` fixture).
|
||||
#[must_use]
|
||||
pub fn with_base_dir(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
|
||||
Self::new(fs, home_dir)
|
||||
}
|
||||
|
||||
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
|
||||
fn transcript_path(&self, conversation_id: &str, cwd: &ProjectPath) -> RemotePath {
|
||||
let base = self.home_dir.trim_end_matches(['/', '\\']);
|
||||
let encoded = encode_cwd(cwd.as_str());
|
||||
RemotePath::new(format!(
|
||||
"{base}/.claude/projects/{encoded}/{conversation_id}.jsonl"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
|
||||
///
|
||||
/// **Assumption (documented, isolated, and unit-tested):** Claude flattens the
|
||||
/// absolute cwd into a single directory segment by replacing each path
|
||||
/// separator (`/` or `\`) — and the leading separator — with `-`. So
|
||||
/// `/home/anthony/Documents/Projects/IdeA` becomes
|
||||
/// `-home-anthony-Documents-Projects-IdeA`. We keep this convention in one
|
||||
/// small, tested function so that if Claude's exact encoding differs in some
|
||||
/// edge case, only this seam needs to change. We do not attempt to encode `.`
|
||||
/// or other characters, as the common case (clean absolute project paths) is
|
||||
/// covered and over-encoding would risk pointing at the wrong folder.
|
||||
fn encode_cwd(cwd: &str) -> String {
|
||||
cwd.chars()
|
||||
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One transcript line, as far as we care about it. Everything is optional so
|
||||
/// that an unexpected/partial line never fails deserialization of the fields we
|
||||
/// *can* read; truly unparseable lines are skipped by the caller.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranscriptLine {
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
/// Some Claude transcript variants nest the chat turn under `message`.
|
||||
#[serde(default)]
|
||||
message: Option<InnerMessage>,
|
||||
#[serde(default)]
|
||||
content: Option<Content>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
/// The nested `{ role, content, usage }` object some transcript shapes use.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InnerMessage {
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
content: Option<Content>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
/// Message content is either a plain string or an array of typed blocks.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Content {
|
||||
/// `"content": "hello"`.
|
||||
Text(String),
|
||||
/// `"content": [{ "type": "text", "text": "hello" }, ...]`.
|
||||
Blocks(Vec<ContentBlock>),
|
||||
}
|
||||
|
||||
impl Content {
|
||||
/// Flattens the content to plain text (concatenating text blocks).
|
||||
fn to_text(&self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s.clone(),
|
||||
Self::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.filter_map(|b| b.text.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single content block; we only care about text blocks.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ContentBlock {
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
/// Token usage as Claude reports it on assistant turns.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Usage {
|
||||
#[serde(default)]
|
||||
input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
output_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl TranscriptLine {
|
||||
/// The effective role, looking at the top level then the nested message.
|
||||
fn effective_role(&self) -> Option<&str> {
|
||||
self.role
|
||||
.as_deref()
|
||||
.or_else(|| self.message.as_ref().and_then(|m| m.role.as_deref()))
|
||||
}
|
||||
|
||||
/// The effective content, top level then nested.
|
||||
fn effective_content(&self) -> Option<&Content> {
|
||||
self.content
|
||||
.as_ref()
|
||||
.or_else(|| self.message.as_ref().and_then(|m| m.content.as_ref()))
|
||||
}
|
||||
|
||||
/// The effective usage, top level then nested.
|
||||
fn effective_usage(&self) -> Option<&Usage> {
|
||||
self.usage
|
||||
.as_ref()
|
||||
.or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an already-read transcript body (the JSONL bytes) into best-effort
|
||||
/// [`ConversationDetails`]. Pulled out of the async `details` so it is a pure,
|
||||
/// directly unit-testable function. Malformed lines are silently skipped.
|
||||
fn parse_transcript(body: &[u8]) -> ConversationDetails {
|
||||
let text = String::from_utf8_lossy(body);
|
||||
|
||||
let mut last_topic: Option<String> = None;
|
||||
let mut token_total: u64 = 0;
|
||||
let mut saw_usage = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Malformed line → skip, never fatal.
|
||||
let Ok(parsed) = serde_json::from_str::<TranscriptLine>(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let role = parsed.effective_role();
|
||||
|
||||
// last_topic = the last user message's text (best-effort).
|
||||
if role == Some("user") {
|
||||
if let Some(content) = parsed.effective_content() {
|
||||
let t = content.to_text();
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
last_topic = Some(truncate_topic(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// token_count = cumulative usage across assistant turns (best-effort).
|
||||
if let Some(usage) = parsed.effective_usage() {
|
||||
let input = usage.input_tokens.unwrap_or(0);
|
||||
let output = usage.output_tokens.unwrap_or(0);
|
||||
if input != 0 || output != 0 {
|
||||
saw_usage = true;
|
||||
token_total = token_total
|
||||
.saturating_add(input)
|
||||
.saturating_add(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConversationDetails {
|
||||
last_topic,
|
||||
token_count: saw_usage.then_some(token_total),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncates a topic to [`TOPIC_MAX_LEN`] bytes on a char boundary, appending an
|
||||
/// ellipsis when cut.
|
||||
fn truncate_topic(s: &str) -> String {
|
||||
if s.len() <= TOPIC_MAX_LEN {
|
||||
return s.to_owned();
|
||||
}
|
||||
let mut end = TOPIC_MAX_LEN;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionInspector for ClaudeTranscriptInspector {
|
||||
fn supports(&self, profile: &AgentProfile) -> bool {
|
||||
// Recognise Claude by its conventional context file `CLAUDE.md`,
|
||||
// mirroring the detection in `application::agent::lifecycle`.
|
||||
matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
)
|
||||
}
|
||||
|
||||
async fn details(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
conversation_id: &str,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<ConversationDetails, InspectError> {
|
||||
let path = self.transcript_path(conversation_id, cwd);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => Ok(parse_transcript(&bytes)),
|
||||
Err(FsError::NotFound(_)) => Err(InspectError::NotFound),
|
||||
Err(e) => Err(InspectError::Read(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encode_cwd_flattens_separators_to_dash() {
|
||||
assert_eq!(
|
||||
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
|
||||
"-home-anthony-Documents-Projects-IdeA"
|
||||
);
|
||||
assert_eq!(encode_cwd("/a/b"), "-a-b");
|
||||
// Windows-style separators flatten too.
|
||||
assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extracts_last_user_topic_and_token_sum() {
|
||||
let body = concat!(
|
||||
r#"{"role":"user","content":"first question"}"#,
|
||||
"\n",
|
||||
r#"{"role":"assistant","content":"hi","usage":{"input_tokens":10,"output_tokens":5}}"#,
|
||||
"\n",
|
||||
r#"{"role":"user","content":"second question"}"#,
|
||||
"\n",
|
||||
r#"{"role":"assistant","content":"there","usage":{"input_tokens":20,"output_tokens":7}}"#,
|
||||
"\n",
|
||||
);
|
||||
let d = parse_transcript(body.as_bytes());
|
||||
assert_eq!(d.last_topic.as_deref(), Some("second question"));
|
||||
assert_eq!(d.token_count, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_skips_malformed_lines_without_panicking() {
|
||||
let body = concat!(
|
||||
r#"{"role":"user","content":"valid one"}"#,
|
||||
"\n",
|
||||
"this is not json at all",
|
||||
"\n",
|
||||
r#"{"role":"assistant","usage":{"input_tokens":3,"output_tokens":4}}"#,
|
||||
"\n",
|
||||
"{ broken json",
|
||||
"\n",
|
||||
);
|
||||
let d = parse_transcript(body.as_bytes());
|
||||
assert_eq!(d.last_topic.as_deref(), Some("valid one"));
|
||||
assert_eq!(d.token_count, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_handles_nested_message_and_block_content() {
|
||||
let body = concat!(
|
||||
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"nested topic"}]}}"#,
|
||||
"\n",
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":100,"output_tokens":50}}}"#,
|
||||
"\n",
|
||||
);
|
||||
let d = parse_transcript(body.as_bytes());
|
||||
assert_eq!(d.last_topic.as_deref(), Some("nested topic"));
|
||||
assert_eq!(d.token_count, Some(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_topic_or_usage() {
|
||||
let body = concat!(
|
||||
r#"{"role":"assistant","content":"no usage here"}"#,
|
||||
"\n",
|
||||
);
|
||||
let d = parse_transcript(body.as_bytes());
|
||||
assert_eq!(d.last_topic, None);
|
||||
assert_eq!(d.token_count, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_topic_cuts_long_text() {
|
||||
let long = "x".repeat(TOPIC_MAX_LEN + 50);
|
||||
let t = truncate_topic(&long);
|
||||
assert!(t.ends_with('…'));
|
||||
assert!(t.len() <= TOPIC_MAX_LEN + 4);
|
||||
}
|
||||
}
|
||||
12
crates/infrastructure/src/inspector/mod.rs
Normal file
12
crates/infrastructure/src/inspector/mod.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! Best-effort conversation-transcript inspectors (CONTEXT §T6).
|
||||
//!
|
||||
//! These adapters implement the **optional** [`domain::ports::SessionInspector`]
|
||||
//! port: they read a CLI's on-disk transcript to enrich a resume popup (last
|
||||
//! topic, token count). Each CLI's transcript format stays fully encapsulated in
|
||||
//! its adapter; only the CLI-agnostic [`domain::ports::ConversationDetails`]
|
||||
//! crosses the port boundary. Nothing here is wired as a hard dependency — a
|
||||
//! missing or failing inspector must never block a resume.
|
||||
|
||||
mod claude;
|
||||
|
||||
pub use claude::ClaudeTranscriptInspector;
|
||||
@ -17,6 +17,7 @@ pub mod eventbus;
|
||||
pub mod fs;
|
||||
pub mod git;
|
||||
pub mod id;
|
||||
pub mod inspector;
|
||||
pub mod orchestrator;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
@ -29,6 +30,7 @@ pub use eventbus::TokioBroadcastEventBus;
|
||||
pub use fs::LocalFileSystem;
|
||||
pub use git::Git2Repository;
|
||||
pub use id::UuidGenerator;
|
||||
pub use inspector::ClaudeTranscriptInspector;
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
|
||||
@ -22,9 +22,10 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{
|
||||
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SpawnSpec,
|
||||
AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan,
|
||||
SpawnSpec,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only
|
||||
@ -140,6 +141,40 @@ impl CliAgentRuntime {
|
||||
ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes the session resume/assign arguments for a launch. **Pure** — the
|
||||
/// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan).
|
||||
///
|
||||
/// The profile's optional [`SessionStrategy`] crossed with the per-launch
|
||||
/// [`SessionPlan`] yields the args to append (exhaustive truth table):
|
||||
///
|
||||
/// | `profile.session` | `SessionPlan` | Args added |
|
||||
/// |----------------------------------------|----------------|------------|
|
||||
/// | `None` | any | `[]` |
|
||||
/// | `Some{assign_flag: Some(f), ..}` | `Assign{id}` | `[f, id]` |
|
||||
/// | `Some{resume_flag: r, ..}` | `Resume{id}` | `[r, id]` |
|
||||
/// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) |
|
||||
/// | `Some{..}` | `None` | `[]` |
|
||||
/// | `Some{assign_flag: None, ..}` | `Assign{id}` | `[]` (no flag) |
|
||||
fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec<String> {
|
||||
let Some(strategy) = session else {
|
||||
return Vec::new();
|
||||
};
|
||||
match plan {
|
||||
SessionPlan::None => Vec::new(),
|
||||
SessionPlan::Assign { conversation_id } => match &strategy.assign_flag {
|
||||
Some(flag) => vec![flag.clone(), conversation_id.clone()],
|
||||
None => Vec::new(),
|
||||
},
|
||||
SessionPlan::Resume { conversation_id } => {
|
||||
let mut args = vec![strategy.resume_flag.clone()];
|
||||
if strategy.assign_flag.is_some() {
|
||||
args.push(conversation_id.clone());
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -161,6 +196,7 @@ impl AgentRuntime for CliAgentRuntime {
|
||||
profile: &AgentProfile,
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
let resolved_cwd = Self::resolve_cwd(profile, cwd)?;
|
||||
let plan = Self::injection_plan(&profile.context_injection, ctx);
|
||||
@ -174,6 +210,10 @@ impl AgentRuntime for CliAgentRuntime {
|
||||
args.extend(extra.iter().cloned());
|
||||
}
|
||||
|
||||
// Session resume/assign args come *after* the static + context-injection
|
||||
// args, so re-opening a conversation never disturbs context delivery.
|
||||
args.extend(Self::session_args(profile.session.as_ref(), session));
|
||||
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
args,
|
||||
|
||||
@ -15,9 +15,9 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{
|
||||
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
|
||||
ProcessSpawner, RuntimeError, SpawnSpec,
|
||||
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::ids::ProfileId;
|
||||
use domain::MarkdownDoc;
|
||||
@ -36,6 +36,7 @@ fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile {
|
||||
injection,
|
||||
Some("mycli probe --json".to_owned()),
|
||||
cwd_template,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
@ -97,7 +98,7 @@ fn prepare_convention_file_keeps_args_and_plans_file() {
|
||||
);
|
||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
|
||||
assert_eq!(spec.command, "mycli");
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
|
||||
@ -123,7 +124,7 @@ fn prepare_flag_with_path_substitutes_and_splits() {
|
||||
);
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
|
||||
// static args first, then the substituted+split flag args.
|
||||
assert_eq!(
|
||||
@ -148,7 +149,7 @@ fn prepare_flag_without_path_is_switch_then_path() {
|
||||
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
|
||||
assert_eq!(
|
||||
@ -169,7 +170,7 @@ fn prepare_stdin_keeps_args_and_plans_stdin() {
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
|
||||
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
|
||||
@ -188,7 +189,7 @@ fn prepare_env_keeps_args_and_plans_env() {
|
||||
);
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
|
||||
assert_eq!(
|
||||
@ -212,7 +213,7 @@ fn prepare_substitutes_project_root_in_cwd_template() {
|
||||
);
|
||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
|
||||
}
|
||||
|
||||
@ -222,7 +223,7 @@ fn prepare_empty_cwd_template_defaults_to_base() {
|
||||
let p = profile(ContextInjection::stdin(), "");
|
||||
let base = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &base).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None).unwrap();
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
||||
}
|
||||
|
||||
@ -234,7 +235,7 @@ fn prepare_substitutes_agent_run_dir_in_cwd_template() {
|
||||
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
|
||||
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir).unwrap();
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None).unwrap();
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
|
||||
}
|
||||
|
||||
@ -262,6 +263,7 @@ fn detection_spec_falls_back_to_command_version() {
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@ -327,3 +329,190 @@ fn detect_runs_the_detection_spec_command() {
|
||||
assert_eq!(spec.command, "mycli");
|
||||
assert_eq!(spec.args, vec!["probe", "--json"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — session args (T3 truth table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Like [`profile`] but carries a [`SessionStrategy`]. `Stdin` injection keeps
|
||||
/// the context out of the args so session args are the *only* trailing tokens.
|
||||
fn profile_with_session(session: Option<SessionStrategy>) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(7)),
|
||||
"Test",
|
||||
"mycli",
|
||||
vec!["--static".to_owned(), "arg".to_owned()],
|
||||
ContextInjection::stdin(),
|
||||
Some("mycli probe --json".to_owned()),
|
||||
"{agentRunDir}",
|
||||
session,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/p").unwrap()
|
||||
}
|
||||
|
||||
// Row 1: profile.session == None ⇒ no args added, whatever the plan.
|
||||
#[test]
|
||||
fn session_none_profile_adds_nothing_for_any_plan() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(None);
|
||||
|
||||
for plan in [
|
||||
SessionPlan::None,
|
||||
SessionPlan::Assign { conversation_id: "id-1".to_owned() },
|
||||
SessionPlan::Resume { conversation_id: "id-1".to_owned() },
|
||||
] {
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: Some{assign_flag: Some(f)} + Assign{id} ⇒ [f, id].
|
||||
#[test]
|
||||
fn session_assign_with_flag_emits_flag_and_id() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(Some(
|
||||
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
|
||||
));
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(
|
||||
&p,
|
||||
&ctx(),
|
||||
&root(),
|
||||
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg", "--session-id", "abc"]);
|
||||
}
|
||||
|
||||
// Row 3: Some{resume_flag: r, assign_flag: Some} + Resume{id} ⇒ [r, id].
|
||||
#[test]
|
||||
fn session_resume_with_flag_emits_resume_and_id() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(Some(
|
||||
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
|
||||
));
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(
|
||||
&p,
|
||||
&ctx(),
|
||||
&root(),
|
||||
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg", "--resume", "abc"]);
|
||||
}
|
||||
|
||||
// Row 4: Some{assign_flag: None, resume_flag: r} + Resume{id} ⇒ [r] (degraded).
|
||||
#[test]
|
||||
fn session_resume_without_assign_flag_emits_resume_only() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(
|
||||
&p,
|
||||
&ctx(),
|
||||
&root(),
|
||||
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg", "--continue"]);
|
||||
}
|
||||
|
||||
// Row 5: Some{..} + SessionPlan::None ⇒ nothing added.
|
||||
#[test]
|
||||
fn session_plan_none_with_strategy_adds_nothing() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(Some(
|
||||
SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(),
|
||||
));
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(&p, &ctx(), &root(), &SessionPlan::None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"]);
|
||||
}
|
||||
|
||||
// Row 6: Some{assign_flag: None} + Assign{id} ⇒ nothing (no assign possible).
|
||||
#[test]
|
||||
fn session_assign_without_flag_adds_nothing() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap()));
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(
|
||||
&p,
|
||||
&ctx(),
|
||||
&root(),
|
||||
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"]);
|
||||
}
|
||||
|
||||
// Non-regression: a profile WITHOUT session + SessionPlan::None yields the exact
|
||||
// same args as before T3 (the existing strategy tests already assert these; here
|
||||
// we pin it against an Assign/Resume plan too — still nothing added).
|
||||
#[test]
|
||||
fn no_session_profile_is_unaffected_by_any_plan() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(ContextInjection::stdin(), "{agentRunDir}");
|
||||
|
||||
for plan in [
|
||||
SessionPlan::None,
|
||||
SessionPlan::Assign { conversation_id: "x".to_owned() },
|
||||
SessionPlan::Resume { conversation_id: "x".to_owned() },
|
||||
] {
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Ordering: session args land *after* the context-injection (Flag) args.
|
||||
#[test]
|
||||
fn session_args_come_after_context_injection_args() {
|
||||
let rt = pure_runtime();
|
||||
let p = AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(8)),
|
||||
"Test",
|
||||
"mycli",
|
||||
vec!["--static".to_owned()],
|
||||
ContextInjection::flag("--context-file {path}").unwrap(),
|
||||
Some("mycli probe".to_owned()),
|
||||
"{agentRunDir}",
|
||||
Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let spec = rt
|
||||
.prepare_invocation(
|
||||
&p,
|
||||
&ctx(),
|
||||
&root(),
|
||||
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// static arg, then context-injection args, then session args — in that order.
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec![
|
||||
"--static",
|
||||
"--context-file",
|
||||
".ideai/agent.md",
|
||||
"--session-id",
|
||||
"abc"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
178
crates/infrastructure/tests/inspector_claude.rs
Normal file
178
crates/infrastructure/tests/inspector_claude.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! T6 integration tests for [`ClaudeTranscriptInspector`] against a real temp
|
||||
//! directory and a real [`LocalFileSystem`], exercising the full transcript
|
||||
//! discovery + parsing path:
|
||||
//!
|
||||
//! - a realistic `~/.claude/projects/<encoded-cwd>/<id>.jsonl` fixture →
|
||||
//! `last_topic` + `token_count` extracted;
|
||||
//! - a missing transcript → [`InspectError::NotFound`];
|
||||
//! - malformed lines in the middle → skipped, valid lines still extracted;
|
||||
//! - [`SessionInspector::supports`] true for a `CLAUDE.md` profile, false for an
|
||||
//! `AGENTS.md` one or a non-`conventionFile` injection.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, InspectError, SessionInspector};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::{ClaudeTranscriptInspector, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop. Plays
|
||||
/// the role of the user's `$HOME` so the inspector reads
|
||||
/// `<home>/.claude/projects/...` entirely inside the fixture.
|
||||
struct TempHome(PathBuf);
|
||||
impl TempHome {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-t6-claude-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn path(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
/// Writes a transcript at `<home>/.claude/projects/<encoded-cwd>/<id>.jsonl`,
|
||||
/// mirroring the adapter's own cwd-encoding convention.
|
||||
fn write_transcript(&self, cwd: &str, conversation_id: &str, body: &str) {
|
||||
let encoded: String = cwd
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
|
||||
.collect();
|
||||
let dir = self.0.join(".claude").join("projects").join(&encoded);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{conversation_id}.jsonl")), body).unwrap();
|
||||
}
|
||||
}
|
||||
impl Drop for TempHome {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn inspector(home: &TempHome) -> ClaudeTranscriptInspector {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
ClaudeTranscriptInspector::with_base_dir(fs, home.path())
|
||||
}
|
||||
|
||||
fn claude_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(1)),
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_last_topic_and_token_count_from_fixture() {
|
||||
let home = TempHome::new();
|
||||
let cwd = "/home/dev/Projects/Demo";
|
||||
let id = "11111111-2222-3333-4444-555555555555";
|
||||
let body = concat!(
|
||||
r#"{"role":"user","content":"set up the project"}"#,
|
||||
"\n",
|
||||
r#"{"role":"assistant","content":"on it","usage":{"input_tokens":12,"output_tokens":8}}"#,
|
||||
"\n",
|
||||
r#"{"role":"user","content":"now add tests"}"#,
|
||||
"\n",
|
||||
r#"{"role":"assistant","content":"done","usage":{"input_tokens":30,"output_tokens":20}}"#,
|
||||
"\n",
|
||||
);
|
||||
home.write_transcript(cwd, id, body);
|
||||
|
||||
let insp = inspector(&home);
|
||||
let details = insp
|
||||
.details(&claude_profile(), id, &ProjectPath::new(cwd).unwrap())
|
||||
.await
|
||||
.expect("transcript should be found and parsed");
|
||||
|
||||
assert_eq!(details.last_topic.as_deref(), Some("now add tests"));
|
||||
assert_eq!(details.token_count, Some(70));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_transcript_is_not_found() {
|
||||
let home = TempHome::new();
|
||||
let insp = inspector(&home);
|
||||
let err = insp
|
||||
.details(
|
||||
&claude_profile(),
|
||||
"does-not-exist",
|
||||
&ProjectPath::new("/home/dev/nope").unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect_err("absent transcript must error");
|
||||
assert_eq!(err, InspectError::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_lines_are_skipped_not_fatal() {
|
||||
let home = TempHome::new();
|
||||
let cwd = "/home/dev/Projects/Resilient";
|
||||
let id = "aaaa";
|
||||
let body = concat!(
|
||||
r#"{"role":"user","content":"valid first"}"#,
|
||||
"\n",
|
||||
"{ this is not valid json",
|
||||
"\n",
|
||||
"plain garbage line",
|
||||
"\n",
|
||||
r#"{"role":"assistant","usage":{"input_tokens":5,"output_tokens":6}}"#,
|
||||
"\n",
|
||||
r#"{"role":"user","content":"valid last"}"#,
|
||||
"\n",
|
||||
);
|
||||
home.write_transcript(cwd, id, body);
|
||||
|
||||
let insp = inspector(&home);
|
||||
let details = insp
|
||||
.details(&claude_profile(), id, &ProjectPath::new(cwd).unwrap())
|
||||
.await
|
||||
.expect("readable transcript with bad lines should still parse");
|
||||
|
||||
assert_eq!(details.last_topic.as_deref(), Some("valid last"));
|
||||
assert_eq!(details.token_count, Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_only_claude_md_profiles() {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
let insp = ClaudeTranscriptInspector::new(fs, "/tmp/whatever");
|
||||
|
||||
// CLAUDE.md → supported.
|
||||
assert!(insp.supports(&claude_profile()));
|
||||
|
||||
// AGENTS.md (Codex) → not supported.
|
||||
let codex = AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(2)),
|
||||
"Codex",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!insp.supports(&codex));
|
||||
|
||||
// Non-conventionFile injection (stdin) → not supported.
|
||||
let stdin_profile = AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(3)),
|
||||
"Piped",
|
||||
"aider",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!insp.supports(&stdin_profile));
|
||||
}
|
||||
@ -21,7 +21,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};
|
||||
@ -183,6 +184,7 @@ impl AgentRuntime for FakeRuntime {
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
@ -283,6 +285,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()])));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
@ -301,6 +304,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
bus.clone(),
|
||||
Arc::new(SeqIds(Mutex::new(1))),
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
|
||||
|
||||
@ -46,6 +46,7 @@ fn sample(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user