Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).
## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
toml_config_home(...); AgentProfile::materializes_idea_bridge()
(whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
+ encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
{runDir}/<target>, pousse (home_env, parent) dans spec.env);
guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
sur loopback réel (fakes, zéro token).
## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
mark_idle sur le signal déterministe; branché dans ask_agent.
Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1267 lines
53 KiB
Rust
1267 lines
53 KiB
Rust
//! Adapters d'**exécution structurée** des agents IA (ARCHITECTURE §17.2), pair de
|
|
//! [`crate::runtime`] (TUI/PTY) et [`crate::pty`]. Implémentent le port domaine
|
|
//! [`domain::ports::AgentSession`] et la fabrique [`domain::ports::AgentSessionFactory`].
|
|
//!
|
|
//! # Principe directeur (CRUCIAL, §17.2)
|
|
//!
|
|
//! Le **parsing du format de sortie de chaque CLI est ISOLÉ** dans une fonction pure
|
|
//! dédiée par adapter ([`claude::parse_event`], [`codex::parse_event`]), **séparée**
|
|
//! de la machinerie de process ([`process`]). Les **vrais** formats Claude/Codex
|
|
//! seront confirmés par les spikes **S1** (Claude) et **S2** (Codex) ; quand on les
|
|
//! aura, **seules ces fonctions de parsing changeront**, pas la machinerie.
|
|
//!
|
|
//! # Composants
|
|
//!
|
|
//! - [`process`] : machinerie de process **paramétrable par la commande** (spawn,
|
|
//! pipes, drain ligne-à-ligne, timeout) — substituable par un fake CLI en test.
|
|
//! - [`claude::ClaudeSdkSession`] / [`codex::CodexExecSession`] : les deux adapters.
|
|
//! - [`factory::StructuredSessionFactory`] : route un profil vers le bon adapter.
|
|
//! - [`conformance`] : **fake CLI** scriptable + **harnais de conformité** (Liskov),
|
|
//! réutilisable pour valider le contrat de port des deux adapters hors-réseau.
|
|
|
|
pub mod claude;
|
|
pub mod codex;
|
|
pub mod conformance;
|
|
pub mod factory;
|
|
pub mod process;
|
|
|
|
pub use claude::ClaudeSdkSession;
|
|
pub use codex::CodexExecSession;
|
|
pub use conformance::FakeCli;
|
|
pub use factory::StructuredSessionFactory;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use domain::ids::ProfileId;
|
|
use domain::ports::{
|
|
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
|
|
PreparedContext, ReplyEvent, SessionPlan,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
use domain::{MarkdownDoc, SessionId};
|
|
|
|
use super::claude::{self, ClaudeSdkSession};
|
|
use super::codex::{self, CodexExecSession};
|
|
use super::conformance::harness::assert_agent_session_contract;
|
|
use super::conformance::FakeCli;
|
|
use super::factory::StructuredSessionFactory;
|
|
use super::process::run_turn;
|
|
|
|
// -- Helpers ----------------------------------------------------------
|
|
|
|
fn prepared_ctx() -> PreparedContext {
|
|
PreparedContext {
|
|
content: MarkdownDoc::new("# ctx"),
|
|
relative_path: "CLAUDE.md".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn cwd() -> ProjectPath {
|
|
ProjectPath::new("/").expect("cwd valide")
|
|
}
|
|
|
|
fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile {
|
|
AgentProfile::new(
|
|
ProfileId::new_random(),
|
|
"Profil structuré",
|
|
command,
|
|
Vec::new(),
|
|
ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"),
|
|
None,
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.expect("profil valide")
|
|
.with_structured_adapter(adapter)
|
|
}
|
|
|
|
// -- Machinerie de process (paramétrable, fake CLI) -------------------
|
|
|
|
#[tokio::test]
|
|
async fn run_turn_drains_every_line_in_order() {
|
|
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
|
let lines = run_turn(&fake.spawn_line(), None)
|
|
.await
|
|
.expect("run_turn réussit");
|
|
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_turn_unknown_binary_yields_start_error() {
|
|
let spec = super::process::SpawnLine {
|
|
command: "/binaire/qui/n/existe/pas/idea-xyz".to_owned(),
|
|
args: Vec::new(),
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
};
|
|
let err = run_turn(&spec, None).await.expect_err("doit échouer");
|
|
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
|
}
|
|
|
|
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
|
|
|
#[test]
|
|
fn claude_parse_init_captures_session_id_and_heartbeats() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#,
|
|
)
|
|
.expect("parse ok");
|
|
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
|
// L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1).
|
|
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_parse_rate_limit_event_is_heartbeat() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
|
)
|
|
.expect("parse ok");
|
|
// Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté).
|
|
assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]);
|
|
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn claude_parse_assistant_text_and_tool() {
|
|
let text = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"bonjour"}]},"session_id":"c","parent_tool_use_id":null}"#,
|
|
)
|
|
.expect("parse ok");
|
|
assert_eq!(
|
|
text.events,
|
|
vec![ReplyEvent::TextDelta {
|
|
text: "bonjour".to_owned()
|
|
}]
|
|
);
|
|
|
|
let tool = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]},"session_id":"c","parent_tool_use_id":null}"#,
|
|
)
|
|
.expect("parse ok");
|
|
assert_eq!(
|
|
tool.events,
|
|
vec![ReplyEvent::ToolActivity {
|
|
label: "Read".to_owned()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// Bug multi-blocs CORRIGÉ : une ligne `assistant` portant `[text, tool_use, text]`
|
|
/// produit **3** ReplyEvent dans l'ordre (et non plus le seul premier bloc).
|
|
#[test]
|
|
fn claude_parse_assistant_multiblock_yields_all_events_in_order() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[
|
|
{"type":"text","text":"un"},
|
|
{"type":"tool_use","name":"Read"},
|
|
{"type":"text","text":"deux"}]},"session_id":"c","parent_tool_use_id":null}"#,
|
|
)
|
|
.expect("parse ok");
|
|
assert_eq!(
|
|
parsed.events,
|
|
vec![
|
|
ReplyEvent::TextDelta {
|
|
text: "un".to_owned()
|
|
},
|
|
ReplyEvent::ToolActivity {
|
|
label: "Read".to_owned()
|
|
},
|
|
ReplyEvent::TextDelta {
|
|
text: "deux".to_owned()
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_parse_result_is_final() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse finale","session_id":"conv-123","num_turns":1}"#,
|
|
)
|
|
.expect("parse ok");
|
|
assert_eq!(
|
|
parsed.events,
|
|
vec![ReplyEvent::Final {
|
|
content: "réponse finale".to_owned()
|
|
}]
|
|
);
|
|
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn claude_parse_broken_json_is_decode_error_no_raw_leak() {
|
|
let err = claude::parse_event("{ pas du json").expect_err("doit échouer");
|
|
match err {
|
|
AgentSessionError::Decode(msg) => {
|
|
assert!(
|
|
!msg.contains("pas du json"),
|
|
"le JSON brut ne doit pas fuir"
|
|
);
|
|
}
|
|
other => panic!("attendu Decode, vu: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn claude_parse_empty_and_unknown_lines_are_ignored() {
|
|
assert_eq!(claude::parse_event("").expect("ok"), Default::default());
|
|
let unknown = claude::parse_event(r#"{"type":"telemetry","x":1}"#).expect("ok ignoré");
|
|
assert!(unknown.events.is_empty());
|
|
}
|
|
|
|
// -- parse_event Codex (format RÉEL vérifié 2026-06-09) ---------------
|
|
|
|
#[test]
|
|
fn codex_parse_thread_started_message_and_final() {
|
|
let sess =
|
|
codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok");
|
|
assert_eq!(sess.conversation_id.as_deref(), Some("cx-9"));
|
|
// Le handshake ne capte que le thread_id, sans événement (pas un heartbeat).
|
|
assert!(sess.events.is_empty());
|
|
|
|
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
|
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
|
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
|
let completed =
|
|
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
|
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
|
|
|
let msg = codex::parse_event(
|
|
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
|
)
|
|
.expect("ok");
|
|
assert_eq!(
|
|
msg.events,
|
|
vec![ReplyEvent::Final {
|
|
content: "fini".to_owned()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_parse_broken_json_is_decode_error() {
|
|
let err = codex::parse_event("<<<").expect_err("doit échouer");
|
|
assert!(matches!(err, AgentSessionError::Decode(_)), "vu: {err:?}");
|
|
}
|
|
|
|
// -- Conformité de port (Liskov) — Claude ET Codex --------------------
|
|
|
|
/// Script Claude (format RÉEL) : init → texte → tool_use → texte → result.
|
|
fn claude_script() -> Vec<&'static str> {
|
|
vec![
|
|
r#"{"type":"system","subtype":"init","session_id":"claude-conv-1","cwd":"/tmp","tools":[]}"#,
|
|
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"claude-conv-1"}"#,
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#,
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#,
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"deux"}]},"session_id":"claude-conv-1","parent_tool_use_id":null}"#,
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse Claude","session_id":"claude-conv-1","num_turns":1}"#,
|
|
]
|
|
}
|
|
|
|
/// Script Codex (format RÉEL) : thread.started → turn.started → reasoning item →
|
|
/// agent_message (= Final) → turn.completed.
|
|
fn codex_script() -> Vec<&'static str> {
|
|
vec![
|
|
r#"{"type":"thread.started","thread_id":"codex-conv-1"}"#,
|
|
r#"{"type":"turn.started"}"#,
|
|
r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"…"}}"#,
|
|
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"réponse Codex"}}"#,
|
|
r#"{"type":"turn.completed","usage":{"input_tokens":10848}}"#,
|
|
]
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn claude_session_respects_port_contract() {
|
|
let fake = FakeCli::printing(&claude_script());
|
|
let session: Arc<dyn AgentSession> = Arc::new(ClaudeSdkSession::new(
|
|
SessionId::new_random(),
|
|
fake.command(),
|
|
"/",
|
|
None,
|
|
));
|
|
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn codex_session_respects_port_contract() {
|
|
let fake = FakeCli::printing(&codex_script());
|
|
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
|
|
SessionId::new_random(),
|
|
fake.command(),
|
|
"/",
|
|
None,
|
|
));
|
|
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
|
}
|
|
|
|
/// Le flux est **clos** après le `Final` : drainé une fois, il ne reproduit
|
|
/// rien (l'incarnation « un run par tour » est intrinsèquement bornée).
|
|
#[tokio::test]
|
|
async fn stream_is_closed_after_final() {
|
|
let fake = FakeCli::printing(&claude_script());
|
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
|
let stream = session.send("x").await.expect("send ok");
|
|
let events: Vec<_> = stream.collect();
|
|
let after_final = events
|
|
.iter()
|
|
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
|
.skip(1)
|
|
.count();
|
|
assert_eq!(after_final, 0, "aucun événement après le Final");
|
|
}
|
|
|
|
/// Un JSON cassé **au milieu du flux** remonte `Decode` (jamais de panic).
|
|
#[tokio::test]
|
|
async fn broken_line_in_stream_yields_decode() {
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
|
"{ ceci n'est pas du json",
|
|
]);
|
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
|
match session.send("x").await {
|
|
Err(AgentSessionError::Decode(_)) => {}
|
|
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
|
Ok(_) => panic!("attendu une erreur Decode, vu un flux"),
|
|
}
|
|
}
|
|
|
|
// -- Factory : routage par structured_adapter ------------------------
|
|
|
|
#[tokio::test]
|
|
async fn factory_supports_only_structured_profiles() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let claude = structured_profile(StructuredAdapter::Claude, "claude");
|
|
let codex = structured_profile(StructuredAdapter::Codex, "codex");
|
|
let tui = AgentProfile::new(
|
|
ProfileId::new_random(),
|
|
"Gemini",
|
|
"gemini",
|
|
Vec::new(),
|
|
ContextInjection::convention_file("GEMINI.md").expect("valide"),
|
|
None,
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.expect("profil valide"); // pas de structured_adapter
|
|
|
|
assert!(factory.supports(&claude));
|
|
assert!(factory.supports(&codex));
|
|
assert!(!factory.supports(&tui));
|
|
}
|
|
|
|
/// §17.3/D7 — **strict coherence**: `AgentProfile::is_selectable` (the menu's
|
|
/// selection gate) and `StructuredSessionFactory::supports` (the runtime's
|
|
/// routing gate) must agree on **every** reference profile. If they ever
|
|
/// diverged, the wizard could offer a profile the runtime cannot drive (or
|
|
/// hide one it can). Asserting profile-by-profile over the real catalogue —
|
|
/// including the expected `claude=codex=true`, `gemini=aider=false` truth
|
|
/// table — means this fails the moment either gate changes without the other.
|
|
#[tokio::test]
|
|
async fn supports_and_is_selectable_agree_on_every_reference_profile() {
|
|
use std::collections::HashMap;
|
|
let factory = StructuredSessionFactory::new();
|
|
let profiles = application::reference_profiles();
|
|
|
|
let mut expected: HashMap<&str, bool> = HashMap::new();
|
|
expected.insert("claude", true);
|
|
expected.insert("codex", true);
|
|
expected.insert("gemini", false);
|
|
expected.insert("aider", false);
|
|
|
|
assert_eq!(
|
|
profiles.len(),
|
|
4,
|
|
"catalogue has the four reference profiles"
|
|
);
|
|
for profile in &profiles {
|
|
let selectable = profile.is_selectable();
|
|
assert_eq!(
|
|
selectable,
|
|
factory.supports(profile),
|
|
"is_selectable and supports must agree for `{}`",
|
|
profile.command
|
|
);
|
|
assert_eq!(
|
|
Some(&selectable),
|
|
expected.get(profile.command.as_str()),
|
|
"unexpected selectability for `{}`",
|
|
profile.command
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn factory_routes_claude_and_codex() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let fake = FakeCli::printing(&claude_script());
|
|
|
|
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
|
let session = factory
|
|
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
|
.await
|
|
.expect("start Claude ok");
|
|
let content = drain_final(session.as_ref()).await;
|
|
assert_eq!(content, "réponse Claude");
|
|
|
|
// Codex : routé vers l'adapter Codex (id de session distinct, démarrage ok).
|
|
let fake_cx = FakeCli::printing(&codex_script());
|
|
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
|
let session_cx = factory
|
|
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
|
.await
|
|
.expect("start Codex ok");
|
|
let content_cx = drain_final(session_cx.as_ref()).await;
|
|
assert_eq!(content_cx, "réponse Codex");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn factory_resume_seeds_conversation_id() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let fake = FakeCli::printing(&claude_script());
|
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
|
let session = factory
|
|
.start(
|
|
&claude,
|
|
&prepared_ctx(),
|
|
&cwd(),
|
|
&SessionPlan::Resume {
|
|
conversation_id: "repris-42".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("start resume ok");
|
|
// L'id de reprise amorce la session avant tout tour (pivot model-agnostic).
|
|
assert_eq!(session.conversation_id().as_deref(), Some("repris-42"));
|
|
}
|
|
|
|
async fn drain_final(session: &dyn AgentSession) -> String {
|
|
let stream = session.send("x").await.expect("send ok");
|
|
for event in stream {
|
|
if let ReplyEvent::Final { content } = event {
|
|
return content;
|
|
}
|
|
}
|
|
panic!("aucun Final");
|
|
}
|
|
|
|
// -- Sanity : le PreparedContext et le ContextInjectionPlan ne sont pas
|
|
// requis par l'adapter structuré (le .md est déjà écrit par LaunchAgent).
|
|
#[test]
|
|
fn prepared_context_is_carried_not_required_by_adapter() {
|
|
// Documentation exécutable : un plan de fichier existe côté runtime PTY,
|
|
// mais l'adapter structuré ne le consomme pas (la CLI lit son convention
|
|
// file depuis le cwd). On vérifie juste que le type compose.
|
|
let _plan = ContextInjectionPlan::File {
|
|
target: "CLAUDE.md".to_owned(),
|
|
};
|
|
let _ = prepared_ctx();
|
|
}
|
|
|
|
/// Le timeout de la machinerie tue le process et remonte `Timeout` (un fake CLI
|
|
/// qui dort plus longtemps que la borne).
|
|
#[tokio::test]
|
|
async fn run_turn_honours_timeout() {
|
|
// Fake CLI qui dort 5s avant d'imprimer : la borne 50ms doit déclencher.
|
|
let mut path = std::env::temp_dir();
|
|
// Nom unique (compteur atomique) pour éviter toute collision entre exécutions
|
|
// parallèles répétées de la suite.
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static SLOW_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
let n = SLOW_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
path.push(format!("idea-fake-slow-{}-{n}", std::process::id()));
|
|
// `File::create` + `sync_all` + `drop` ferme le descripteur en écriture AVANT
|
|
// l'exécution : sinon `execve` peut retourner `ETXTBSY` sous charge parallèle.
|
|
{
|
|
use std::io::Write as _;
|
|
let mut f = std::fs::File::create(&path).expect("write");
|
|
f.write_all(b"#!/bin/sh\nsleep 5\nprintf 'tard\\n'\n")
|
|
.expect("write");
|
|
f.sync_all().expect("sync");
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
std::fs::set_permissions(&path, perms).unwrap();
|
|
}
|
|
// Garantit que le binaire est exec-ready (plus de `ETXTBSY`) avant le spawn
|
|
// mesuré : on probe en boucle, mais on **tue immédiatement** l'enfant (il
|
|
// dormirait 5s) — on ne veut prouver que l'exécutabilité, pas attendre.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
match Command::new(&path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
{
|
|
Ok(mut child) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
break;
|
|
}
|
|
Err(e) if e.raw_os_error() == Some(26) && Instant::now() < deadline => {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
let spec = super::process::SpawnLine {
|
|
command: path.to_string_lossy().into_owned(),
|
|
args: Vec::new(),
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
};
|
|
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
|
.await
|
|
.expect_err("doit expirer");
|
|
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
// =====================================================================
|
|
// DURCISSEMENT QA (lot D2) — couvre les axes non couverts par les tests
|
|
// initiaux. Tout passe par le FakeCli (jamais le vrai claude/codex).
|
|
// =====================================================================
|
|
|
|
// -- Helper : fake CLI qui enregistre son argv dans un fichier sidecar,
|
|
// puis rejoue un script de lignes. Permet de PROUVER que la commande
|
|
// générée porte bien le flag de reprise (`--resume <id>`).
|
|
fn make_recording_fake(script: &[&str]) -> (String, std::path::PathBuf) {
|
|
use std::io::Write as _;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static C: AtomicU64 = AtomicU64::new(0);
|
|
let n = C.fetch_add(1, Ordering::Relaxed);
|
|
let mut bin = std::env::temp_dir();
|
|
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
|
let mut argv = std::env::temp_dir();
|
|
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
|
|
|
let mut s = String::from("#!/bin/sh\n");
|
|
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
|
|
s.push_str(&format!(
|
|
"for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{}'; done\n",
|
|
argv.display()
|
|
));
|
|
for line in script {
|
|
s.push_str("printf '%s\\n' ");
|
|
// réutilise le quoting de conformance via un quoting local simple.
|
|
s.push('\'');
|
|
s.push_str(&line.replace('\'', "'\\''"));
|
|
s.push_str("'\n");
|
|
}
|
|
{
|
|
let mut f = std::fs::File::create(&bin).expect("create rec fake");
|
|
f.write_all(s.as_bytes()).expect("write rec fake");
|
|
f.sync_all().expect("sync rec fake");
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut p = std::fs::metadata(&bin).unwrap().permissions();
|
|
p.set_mode(0o755);
|
|
std::fs::set_permissions(&bin, p).unwrap();
|
|
}
|
|
super::conformance::wait_until_executable(&bin);
|
|
(bin.to_string_lossy().into_owned(), argv)
|
|
}
|
|
|
|
// ---- Claude parse_event : plusieurs blocs, robustesse ---------------
|
|
|
|
/// Messages successifs : chaque message produit ses événements ; ici un par
|
|
/// message (texte puis tool_use).
|
|
#[test]
|
|
fn claude_multiple_messages_each_yield_their_events() {
|
|
let t1 = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
|
)
|
|
.unwrap();
|
|
let t2 = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]);
|
|
assert_eq!(
|
|
t2.events,
|
|
vec![ReplyEvent::ToolActivity {
|
|
label: "Bash".into()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// Bug multi-blocs CORRIGÉ : un **seul** message portant plusieurs blocs
|
|
/// `text`/`tool_use` rend TOUS ses blocs dans l'ordre (plus de perte).
|
|
#[test]
|
|
fn claude_multiblock_message_yields_every_block() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"content":[
|
|
{"type":"text","text":"un"},
|
|
{"type":"tool_use","name":"Read"},
|
|
{"type":"text","text":"deux"}]}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
parsed.events,
|
|
vec![
|
|
ReplyEvent::TextDelta { text: "un".into() },
|
|
ReplyEvent::ToolActivity {
|
|
label: "Read".into()
|
|
},
|
|
ReplyEvent::TextDelta {
|
|
text: "deux".into()
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
/// `tool_use` sans `name` ⇒ label de repli « outil » (jamais de panic).
|
|
#[test]
|
|
fn claude_tool_use_without_name_falls_back() {
|
|
let parsed = claude::parse_event(
|
|
r#"{"type":"assistant","message":{"content":[{"type":"tool_use"}]}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
parsed.events,
|
|
vec![ReplyEvent::ToolActivity {
|
|
label: "outil".into()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// `result` sans champ `result` ⇒ aucun event (pas de panic, pas de Final vide
|
|
/// fabriqué). Documente la robustesse du parser.
|
|
#[test]
|
|
fn claude_result_without_content_yields_no_event() {
|
|
let parsed = claude::parse_event(r#"{"type":"result","subtype":"success"}"#).unwrap();
|
|
assert!(parsed.events.is_empty());
|
|
}
|
|
|
|
/// Ligne whitespace-only (espaces/tabs) ⇒ ignorée comme une ligne vide.
|
|
#[test]
|
|
fn claude_whitespace_line_is_ignored() {
|
|
assert_eq!(claude::parse_event(" \t ").unwrap(), Default::default());
|
|
}
|
|
|
|
/// JSON valide mais non-objet (tableau, nombre) ⇒ pas de type ⇒ ignoré, jamais
|
|
/// de panic, jamais de Decode.
|
|
#[test]
|
|
fn claude_valid_non_object_json_is_ignored() {
|
|
assert!(claude::parse_event("[1,2,3]").unwrap().events.is_empty());
|
|
assert!(claude::parse_event("42").unwrap().events.is_empty());
|
|
}
|
|
|
|
// ---- Codex parse_event (format RÉEL vérifié 2026-06-09) -------------
|
|
|
|
/// Un item non-`agent_message` (reasoning/command/…) ⇒ ToolActivity (label = type).
|
|
#[test]
|
|
fn codex_non_agent_message_item_is_tool_activity() {
|
|
let r = codex::parse_event(
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"…"}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
r.events,
|
|
vec![ReplyEvent::ToolActivity {
|
|
label: "reasoning".into()
|
|
}]
|
|
);
|
|
let c =
|
|
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
|
|
.unwrap();
|
|
assert_eq!(
|
|
c.events,
|
|
vec![ReplyEvent::ToolActivity {
|
|
label: "command".into()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// `agent_message` ⇒ Final (porte le texte de réponse).
|
|
#[test]
|
|
fn codex_agent_message_is_final() {
|
|
let m = codex::parse_event(
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"bonjour"}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
m.events,
|
|
vec![ReplyEvent::Final {
|
|
content: "bonjour".into()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// `agent_message` sans `text` ⇒ Final avec contenu vide (pas de panic).
|
|
#[test]
|
|
fn codex_agent_message_without_text_is_empty_final() {
|
|
let m = codex::parse_event(
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
m.events,
|
|
vec![ReplyEvent::Final {
|
|
content: String::new()
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// `thread.started` capte le `thread_id` ; pas d'événement.
|
|
#[test]
|
|
fn codex_thread_started_captures_thread_id() {
|
|
let p = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-7"}"#).unwrap();
|
|
assert_eq!(p.conversation_id.as_deref(), Some("cx-7"));
|
|
assert!(p.events.is_empty());
|
|
}
|
|
|
|
/// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont
|
|
/// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.)
|
|
#[test]
|
|
fn codex_empty_and_unknown_ignored() {
|
|
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
|
// Un `type` inconnu reste ignoré (robustesse), pas un heartbeat.
|
|
assert!(codex::parse_event(r#"{"type":"telemetry"}"#)
|
|
.unwrap()
|
|
.events
|
|
.is_empty());
|
|
}
|
|
|
|
// ---- Machinerie process via FakeCli ---------------------------------
|
|
|
|
/// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui
|
|
/// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un
|
|
/// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus
|
|
/// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut
|
|
/// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au
|
|
/// premier `Final`.
|
|
#[tokio::test]
|
|
async fn codex_stream_closed_after_final() {
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"thread.started","thread_id":"c"}"#,
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"a"}}"#,
|
|
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
|
r#"{"type":"turn.completed","usage":{}}"#,
|
|
]);
|
|
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None);
|
|
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
|
let finals = events
|
|
.iter()
|
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
|
.count();
|
|
assert_eq!(finals, 1, "exactement un Final");
|
|
// Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre.
|
|
let after_final_terminals = events
|
|
.iter()
|
|
.skip_while(|e| !matches!(e, ReplyEvent::Final { .. }))
|
|
.skip(1)
|
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
|
.count();
|
|
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
|
}
|
|
|
|
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
|
/// niveau de l'adapter — `send()` renvoie Ok avec uniquement des deltas et AUCUN
|
|
/// `Final`. Le §17.9 D2 mentionne « flux sans Final ⇒ Io » ; l'adapter actuel ne
|
|
/// l'applique pas (c'est `send_blocking` côté application, lot D1, qui transforme
|
|
/// l'absence de Final en Timeout). Ce test PINNE le comportement réel observé.
|
|
#[tokio::test]
|
|
async fn stream_without_final_is_silently_ok_at_adapter_level() {
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
|
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
|
]);
|
|
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
|
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
|
let finals = events
|
|
.iter()
|
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
|
.count();
|
|
assert_eq!(finals, 0, "comportement actuel: aucun Final fabriqué");
|
|
// L'id de conversation est tout de même capté (init lu).
|
|
assert_eq!(s.conversation_id().as_deref(), Some("c"));
|
|
}
|
|
|
|
/// EOF immédiat (binaire qui n'imprime rien) ⇒ flux vide, pas d'erreur, pas de
|
|
/// panic. La machinerie draine proprement un stdout vide.
|
|
#[tokio::test]
|
|
async fn run_turn_empty_output_is_ok() {
|
|
let fake = FakeCli::printing(&[]);
|
|
let lines = run_turn(&fake.spawn_line(), None).await.expect("ok");
|
|
assert!(lines.is_empty());
|
|
}
|
|
|
|
/// stdin fourni : la machinerie l'écrit et ferme le pipe (EOF) sans bloquer.
|
|
#[tokio::test]
|
|
async fn run_turn_writes_stdin_then_eof() {
|
|
let fake = FakeCli::printing(&["pong"]);
|
|
let mut spec = fake.spawn_line();
|
|
spec.stdin = Some("ping".to_owned());
|
|
let lines = run_turn(&spec, None).await.expect("ok");
|
|
assert_eq!(lines, vec!["pong"]);
|
|
}
|
|
|
|
/// Timeout via la machinerie + FakeCli lent (≈ le test existant, mais bâti sur
|
|
/// un fake qui dort) : borne courte ⇒ `Timeout`.
|
|
#[tokio::test]
|
|
async fn run_turn_timeout_on_slow_fake() {
|
|
// Fake dormeur déterministe (sleep 5s) borné par un timeout de 50ms.
|
|
let mut path = std::env::temp_dir();
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static C: AtomicU64 = AtomicU64::new(0);
|
|
path.push(format!(
|
|
"idea-slow2-{}-{}",
|
|
std::process::id(),
|
|
C.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
{
|
|
use std::io::Write as _;
|
|
let mut f = std::fs::File::create(&path).unwrap();
|
|
f.write_all(b"#!/bin/sh\nsleep 5\n").unwrap();
|
|
f.sync_all().unwrap();
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut p = std::fs::metadata(&path).unwrap().permissions();
|
|
p.set_mode(0o755);
|
|
std::fs::set_permissions(&path, p).unwrap();
|
|
}
|
|
// Probe non-bloquant (kill immédiat) pour écarter ETXTBSY.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::process::{Command, Stdio};
|
|
use std::time::Instant;
|
|
let dl = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
match Command::new(&path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
{
|
|
Ok(mut c) => {
|
|
let _ = c.kill();
|
|
let _ = c.wait();
|
|
break;
|
|
}
|
|
Err(e) if e.raw_os_error() == Some(26) && Instant::now() < dl => {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
let spec = super::process::SpawnLine {
|
|
command: path.to_string_lossy().into_owned(),
|
|
args: Vec::new(),
|
|
cwd: "/".to_owned(),
|
|
env: Vec::new(),
|
|
stdin: None,
|
|
};
|
|
let err = run_turn(&spec, Some(Duration::from_millis(50)))
|
|
.await
|
|
.expect_err("doit expirer");
|
|
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
// ---- Adapters : reprise + commande générée porte le flag --------------
|
|
|
|
/// PROUVE que la commande réellement lancée porte `--resume <id>` quand la
|
|
/// session a été amorcée en reprise (via le sidecar argv du fake enregistreur).
|
|
#[tokio::test]
|
|
async fn claude_resume_command_carries_resume_flag() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"resume-id","num_turns":1}"#,
|
|
]);
|
|
let session = ClaudeSdkSession::new(
|
|
SessionId::new_random(),
|
|
cmd.clone(),
|
|
"/",
|
|
Some("resume-id".to_owned()),
|
|
);
|
|
// conversation_id amorcé avant tout tour.
|
|
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
|
let _ = session.send("salut").await.expect("send ok");
|
|
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv enregistré");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
assert!(
|
|
args.contains(&"--resume"),
|
|
"argv doit porter --resume, vu: {args:?}"
|
|
);
|
|
assert!(
|
|
args.contains(&"resume-id"),
|
|
"argv doit porter l'id de reprise, vu: {args:?}"
|
|
);
|
|
assert!(
|
|
args.contains(&"salut"),
|
|
"argv doit porter le prompt, vu: {args:?}"
|
|
);
|
|
// Format RÉEL : --output-format stream-json --verbose sont requis.
|
|
assert!(
|
|
args.contains(&"--output-format") && args.contains(&"stream-json"),
|
|
"argv doit porter --output-format stream-json, vu: {args:?}"
|
|
);
|
|
assert!(
|
|
args.contains(&"--verbose"),
|
|
"argv doit porter --verbose, vu: {args:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
/// Idem Codex : `codex exec resume <thread_id> --json --skip-git-repo-check <prompt>`
|
|
/// (format RÉEL : sous-commande `resume`, pas un flag `--resume`).
|
|
#[tokio::test]
|
|
async fn codex_resume_command_carries_resume_subcommand() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
|
]);
|
|
let session = CodexExecSession::new(
|
|
SessionId::new_random(),
|
|
cmd.clone(),
|
|
"/",
|
|
Some("cx-id".to_owned()),
|
|
);
|
|
let _ = session.send("vas-y").await.expect("send ok");
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
assert!(args.contains(&"exec"), "vu: {args:?}");
|
|
assert!(args.contains(&"resume"), "vu: {args:?}");
|
|
assert!(args.contains(&"cx-id"), "vu: {args:?}");
|
|
assert!(args.contains(&"--json"), "vu: {args:?}");
|
|
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
|
|
assert!(args.contains(&"vas-y"), "vu: {args:?}");
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
/// Une conversation NEUVE (pas de seed) NE porte PAS `--resume` au premier tour,
|
|
/// mais le capte après (init) ⇒ le SECOND tour, lui, porte `--resume`.
|
|
#[tokio::test]
|
|
async fn claude_new_then_resume_flag_appears_on_second_turn() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
|
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
|
|
]);
|
|
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
|
assert_eq!(session.conversation_id(), None);
|
|
let _ = session.send("t1").await.expect("t1");
|
|
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
|
let _ = session.send("t2").await.expect("t2");
|
|
|
|
let recorded = std::fs::read_to_string(&argv).unwrap();
|
|
// Le sidecar accumule les deux tours. Le 1er tour ne doit PAS avoir d'id avant
|
|
// capture ; après capture le 2e tour porte --resume captured-1. On vérifie la
|
|
// présence globale (les deux tours sont concaténés).
|
|
assert!(
|
|
recorded.contains("--resume"),
|
|
"2e tour doit porter --resume"
|
|
);
|
|
assert!(recorded.contains("captured-1"));
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
// ---- Factory : reprise amorce le seed + route correctement -----------
|
|
|
|
/// Reprise via la factory : `SessionPlan::Resume` amorce le seed, et la session
|
|
/// résultante porte bien l'id AVANT tout tour (déjà partiellement couvert ; ici
|
|
/// on couvre AUSSI Codex).
|
|
#[tokio::test]
|
|
async fn factory_resume_seeds_codex() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
|
]);
|
|
let codex = structured_profile(StructuredAdapter::Codex, &fake.command());
|
|
let session = factory
|
|
.start(
|
|
&codex,
|
|
&prepared_ctx(),
|
|
&cwd(),
|
|
&SessionPlan::Resume {
|
|
conversation_id: "cx-resume".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("start resume codex");
|
|
assert_eq!(session.conversation_id().as_deref(), Some("cx-resume"));
|
|
}
|
|
|
|
/// `SessionPlan::Assign` (assigne un id côté IdeA mais conversation moteur neuve)
|
|
/// ⇒ pas de seed moteur (l'id moteur sera capté au 1er tour). Couvre la 3e
|
|
/// variante de SessionPlan, non testée jusqu'ici.
|
|
#[tokio::test]
|
|
async fn factory_assign_does_not_seed_engine_id() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let fake = FakeCli::printing(&claude_script());
|
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
|
let session = factory
|
|
.start(
|
|
&claude,
|
|
&prepared_ctx(),
|
|
&cwd(),
|
|
&SessionPlan::Assign {
|
|
conversation_id: "ignored-by-engine".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("start assign");
|
|
// Assign n'amorce PAS le moteur : conversation_id reste None avant tour.
|
|
assert_eq!(session.conversation_id(), None);
|
|
}
|
|
|
|
/// La factory échoue proprement (`Start`) si on lui passe un profil SANS adapter
|
|
/// structuré (cohérence avec `supports`). Garde-fou défensif.
|
|
#[tokio::test]
|
|
async fn factory_start_rejects_non_structured_profile() {
|
|
let factory = StructuredSessionFactory::new();
|
|
let tui = AgentProfile::new(
|
|
ProfileId::new_random(),
|
|
"Aider",
|
|
"aider",
|
|
Vec::new(),
|
|
ContextInjection::convention_file("AGENTS.md").expect("valide"),
|
|
None,
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.expect("profil valide");
|
|
match factory
|
|
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
|
.await
|
|
{
|
|
Err(AgentSessionError::Start(_)) => {}
|
|
Err(other) => panic!("attendu Start, vu: {other:?}"),
|
|
Ok(_) => panic!("la factory ne doit pas démarrer un profil non structuré"),
|
|
}
|
|
}
|
|
|
|
/// Codex passe le harnais de conformité partagé (substituabilité Liskov) — déjà
|
|
/// présent ; on ajoute un script Codex MINIMAL (zéro delta) pour prouver que le
|
|
/// contrat « ≥0 deltas puis un Final » tient avec zéro delta.
|
|
#[tokio::test]
|
|
async fn codex_contract_holds_with_zero_deltas() {
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"thread.started","thread_id":"cx-0"}"#,
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"direct"}}"#,
|
|
]);
|
|
let session: Arc<dyn AgentSession> = Arc::new(CodexExecSession::new(
|
|
SessionId::new_random(),
|
|
fake.command(),
|
|
"/",
|
|
None,
|
|
));
|
|
assert_agent_session_contract(session, "cx-0", "direct").await;
|
|
}
|
|
|
|
// =====================================================================
|
|
// DURCISSEMENT QA (lot D2-bis) — bouche les axes RÉSIDUELS du périmètre :
|
|
// (a) flot COMPLET init→assistant(MULTI-blocs sur une SEULE ligne)→result
|
|
// drainé via `send()` (et pas seulement `parse_event`) : on prouve que
|
|
// l'aplatissement multi-blocs tient bout-en-bout et qu'il y a UN Final ;
|
|
// (b) commande NEUVE : Claude ne porte PAS `--resume` au 1er tour (isolé,
|
|
// pas un `contains` global) et porte la base réelle ;
|
|
// (c) commande NEUVE : Codex porte `exec --json --skip-git-repo-check` SANS
|
|
// sous-commande `resume`, et `resume` n'apparaît PAS avant capture.
|
|
// Tout passe par le FakeCli/sidecar argv — jamais le vrai claude/codex.
|
|
// =====================================================================
|
|
|
|
/// Flot COMPLET Claude où l'assistant émet ses blocs sur **une seule ligne**
|
|
/// `content:[text,tool_use,text]` : drainé via `send()`, on doit obtenir, dans
|
|
/// l'ordre, TextDelta("a"), ToolActivity("T"), TextDelta("b") PUIS exactement
|
|
/// **un** Final("..."). Couvre l'aplatissement multi-blocs bout-en-bout (le
|
|
/// harnais de conformité, lui, n'utilise que des lignes mono-bloc).
|
|
#[tokio::test]
|
|
async fn claude_full_flow_multiblock_line_flattens_then_single_final() {
|
|
let fake = FakeCli::printing(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"flow-1","cwd":"/tmp","tools":[]}"#,
|
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
|
|
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
|
|
]);
|
|
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None);
|
|
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
|
assert_eq!(
|
|
events,
|
|
vec![
|
|
// L'init `system` émet un heartbeat (vivacité non terminale, lot 1).
|
|
ReplyEvent::Heartbeat,
|
|
ReplyEvent::TextDelta { text: "a".into() },
|
|
ReplyEvent::ToolActivity { label: "T".into() },
|
|
ReplyEvent::TextDelta { text: "b".into() },
|
|
ReplyEvent::Final {
|
|
content: "final-ok".into()
|
|
},
|
|
],
|
|
"heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final"
|
|
);
|
|
// Un seul Final, en dernière position (redondant mais explicite).
|
|
assert_eq!(
|
|
events
|
|
.iter()
|
|
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(session.conversation_id().as_deref(), Some("flow-1"));
|
|
}
|
|
|
|
/// Commande NEUVE Claude : au 1er tour (aucun seed), l'argv ne doit PAS porter
|
|
/// `--resume` ni d'id, mais DOIT porter `-p <prompt> --output-format stream-json
|
|
/// --verbose`. (Le test existant prouve l'apparition au 2e tour via un `contains`
|
|
/// global ; ici on isole le 1er tour pour prouver l'ABSENCE.)
|
|
#[tokio::test]
|
|
async fn claude_new_conversation_command_has_no_resume() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
|
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
|
|
]);
|
|
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
|
let _ = session.send("bonjour").await.expect("send ok");
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
assert!(
|
|
!args.contains(&"--resume"),
|
|
"1er tour NEUF ne doit PAS porter --resume, vu: {args:?}"
|
|
);
|
|
// La base RÉELLE est bien présente.
|
|
assert!(args.contains(&"-p"), "vu: {args:?}");
|
|
assert!(args.contains(&"bonjour"), "vu: {args:?}");
|
|
assert!(
|
|
args.contains(&"--output-format") && args.contains(&"stream-json"),
|
|
"vu: {args:?}"
|
|
);
|
|
assert!(args.contains(&"--verbose"), "vu: {args:?}");
|
|
// L'ordre RÉEL : -p précède son prompt, qui précède --output-format.
|
|
let p = args.iter().position(|a| *a == "-p").unwrap();
|
|
let of = args.iter().position(|a| *a == "--output-format").unwrap();
|
|
assert!(p < of, "-p doit précéder --output-format, vu: {args:?}");
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
/// Commande NEUVE Codex : au 1er tour (aucun seed), l'argv doit porter
|
|
/// `exec --json --skip-git-repo-check <prompt>` SANS la sous-commande `resume`.
|
|
#[tokio::test]
|
|
async fn codex_new_conversation_command_has_no_resume_subcommand() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
|
]);
|
|
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
|
let _ = session.send("salut").await.expect("send ok");
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
assert!(args.contains(&"exec"), "vu: {args:?}");
|
|
assert!(
|
|
!args.contains(&"resume"),
|
|
"1er tour NEUF ne doit PAS porter la sous-commande resume, vu: {args:?}"
|
|
);
|
|
assert!(args.contains(&"--json"), "vu: {args:?}");
|
|
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
|
|
assert!(args.contains(&"salut"), "vu: {args:?}");
|
|
// `exec` est bien la 1re sous-commande (position 0 de l'argv).
|
|
assert_eq!(
|
|
args.first(),
|
|
Some(&"exec"),
|
|
"exec doit ouvrir l'argv, vu: {args:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
// =====================================================================
|
|
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
|
|
// d'écriture Codex : la commande générée porte EXACTEMENT
|
|
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, <prompt>]
|
|
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
|
|
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
|
|
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
|
|
// qu'aucune régression ne réintroduise un flag inconnu de la sous-commande.
|
|
// Prouvé via le sidecar argv du fake enregistreur (jamais le vrai codex).
|
|
// =====================================================================
|
|
|
|
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
|
|
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
|
|
/// pas de `--ask-for-approval`.
|
|
#[tokio::test]
|
|
async fn codex_new_conversation_command_carries_exact_args() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
|
]);
|
|
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None);
|
|
let _ = session.send("salut").await.expect("send ok");
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"exec",
|
|
"--json",
|
|
"--skip-git-repo-check",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"salut",
|
|
],
|
|
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
|
|
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
|
|
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
|
|
/// pas de `--ask-for-approval`.
|
|
#[tokio::test]
|
|
async fn codex_resume_command_carries_exact_args() {
|
|
let (cmd, argv) = make_recording_fake(&[
|
|
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
|
]);
|
|
let session = CodexExecSession::new(
|
|
SessionId::new_random(),
|
|
cmd.clone(),
|
|
"/",
|
|
Some("cx-id".to_owned()),
|
|
);
|
|
let _ = session.send("vas-y").await.expect("send ok");
|
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
|
let args: Vec<&str> = recorded.lines().collect();
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"exec",
|
|
"resume",
|
|
"cx-id",
|
|
"--json",
|
|
"--skip-git-repo-check",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"vas-y",
|
|
],
|
|
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
|
);
|
|
let _ = std::fs::remove_file(&cmd);
|
|
let _ = std::fs::remove_file(&argv);
|
|
}
|
|
}
|