fix(agent): adapters Claude/Codex au format CLI réel (spikes S1/S2 résolus)
Formats vérifiés sur les vraies CLI le 2026-06-09 : - Claude (claude -p … --output-format stream-json --verbose ; reprise --resume <session_id>) : parse system/init (session_id), ignore rate_limit_event + types inconnus, assistant.content[] ITÉRÉ (fix multi-blocs), result→Final. Flag --verbose ajouté à build_spawn_line. - Codex (codex exec --json --skip-git-repo-check ; reprise codex exec resume <thread_id>) : thread.started→conversation_id, item.completed/agent_message →Final, autres items→ToolActivity, turn.*/inconnu ignorés. parse_event émet désormais Vec<ReplyEvent> (multi-blocs) ; drain aplatit. Scripts fake CLI passés aux lignes réelles. Tests : session 46 + intégration, workspace vert. À vérifier à l'intégration (D3) : reprise Codex (ordre d'args), et lancement Codex avec --sandbox workspace-write + approbation non bloquante (hors parsing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -103,53 +103,90 @@ mod tests {
|
||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
// -- parse_event Claude (schéma SUPPOSÉ S1) ---------------------------
|
||||
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
||||
|
||||
#[test]
|
||||
fn claude_parse_init_captures_session_id_without_event() {
|
||||
let parsed =
|
||||
claude::parse_event(r#"{"type":"system","subtype":"init","session_id":"conv-123"}"#)
|
||||
.expect("parse ok");
|
||||
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"));
|
||||
assert!(parsed.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_parse_rate_limit_event_is_ignored() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
// Type inconnu/non pertinent : ignoré (mais session_id tout de même capté).
|
||||
assert!(parsed.events.is_empty());
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
assert_eq!(parsed.event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_parse_assistant_text_and_tool() {
|
||||
let text = claude::parse_event(
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"bonjour"}]}}"#,
|
||||
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.event,
|
||||
Some(ReplyEvent::TextDelta {
|
||||
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"}]}}"#,
|
||||
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.event,
|
||||
Some(ReplyEvent::ToolActivity {
|
||||
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","result":"réponse finale","session_id":"conv-123"}"#,
|
||||
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.event,
|
||||
Some(ReplyEvent::Final {
|
||||
parsed.events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "réponse finale".to_owned()
|
||||
})
|
||||
}]
|
||||
);
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("conv-123"));
|
||||
}
|
||||
@ -172,33 +209,36 @@ mod tests {
|
||||
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_eq!(unknown.event, None);
|
||||
assert!(unknown.events.is_empty());
|
||||
}
|
||||
|
||||
// -- parse_event Codex (schéma SUPPOSÉ S2) ----------------------------
|
||||
// -- parse_event Codex (format RÉEL vérifié 2026-06-09) ---------------
|
||||
|
||||
#[test]
|
||||
fn codex_parse_session_message_and_final() {
|
||||
let sess =
|
||||
codex::parse_event(r#"{"type":"session","conversation_id":"cx-9"}"#).expect("ok");
|
||||
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"));
|
||||
assert_eq!(sess.event, None);
|
||||
assert!(sess.events.is_empty());
|
||||
|
||||
let msg = codex::parse_event(r#"{"type":"message","text":"salut"}"#).expect("ok");
|
||||
assert_eq!(
|
||||
msg.event,
|
||||
Some(ReplyEvent::TextDelta {
|
||||
text: "salut".to_owned()
|
||||
})
|
||||
);
|
||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||
assert!(started.events.is_empty());
|
||||
|
||||
let fin = codex::parse_event(r#"{"type":"result","text":"fini"}"#).expect("ok");
|
||||
let msg = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#,
|
||||
)
|
||||
.expect("ok");
|
||||
assert_eq!(
|
||||
fin.event,
|
||||
Some(ReplyEvent::Final {
|
||||
msg.events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "fini".to_owned()
|
||||
})
|
||||
}]
|
||||
);
|
||||
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#)
|
||||
.expect("ok");
|
||||
assert!(completed.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -209,24 +249,27 @@ mod tests {
|
||||
|
||||
// -- Conformité de port (Liskov) — Claude ET Codex --------------------
|
||||
|
||||
/// Script Claude (schéma SUPPOSÉ S1) : init → texte → tool_use → result.
|
||||
/// 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"}"#,
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"un "}]}}"#,
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read"}]}}"#,
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"deux"}]}}"#,
|
||||
r#"{"type":"result","subtype":"success","result":"réponse Claude","session_id":"claude-conv-1"}"#,
|
||||
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 (schéma SUPPOSÉ S2) : session → message → tool → result.
|
||||
/// 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":"session","conversation_id":"codex-conv-1"}"#,
|
||||
r#"{"type":"delta","text":"trav"}"#,
|
||||
r#"{"type":"tool_call","name":"bash"}"#,
|
||||
r#"{"type":"result","text":"réponse Codex"}"#,
|
||||
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}}"#,
|
||||
]
|
||||
}
|
||||
|
||||
@ -495,11 +538,10 @@ mod tests {
|
||||
|
||||
// ---- Claude parse_event : plusieurs blocs, robustesse ---------------
|
||||
|
||||
/// Plusieurs `tool_use` / blocs texte dans des messages successifs : chaque
|
||||
/// message produit UN événement (le premier bloc pertinent). On documente la
|
||||
/// limite connue : un message multi-blocs ne rend que son **premier** bloc.
|
||||
/// Messages successifs : chaque message produit ses événements ; ici un par
|
||||
/// message (texte puis tool_use).
|
||||
#[test]
|
||||
fn claude_multiple_messages_each_yield_one_event() {
|
||||
fn claude_multiple_messages_each_yield_their_events() {
|
||||
let t1 = claude::parse_event(
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
||||
)
|
||||
@ -508,20 +550,19 @@ mod tests {
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(t1.event, Some(ReplyEvent::TextDelta { text: "a".into() }));
|
||||
assert_eq!(t1.events, vec![ReplyEvent::TextDelta { text: "a".into() }]);
|
||||
assert_eq!(
|
||||
t2.event,
|
||||
Some(ReplyEvent::ToolActivity {
|
||||
t2.events,
|
||||
vec![ReplyEvent::ToolActivity {
|
||||
label: "Bash".into()
|
||||
})
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// LIMITE CONNUE (à arbitrer dev/archi) : un **seul** message portant plusieurs
|
||||
/// blocs `text`/`tool_use` ne rend que le PREMIER bloc pertinent — les blocs
|
||||
/// suivants sont perdus. Ce test PINNE le comportement actuel (pas un échec).
|
||||
/// 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_keeps_only_first_block_known_limit() {
|
||||
fn claude_multiblock_message_yields_every_block() {
|
||||
let parsed = claude::parse_event(
|
||||
r#"{"type":"assistant","message":{"content":[
|
||||
{"type":"text","text":"un"},
|
||||
@ -529,10 +570,17 @@ mod tests {
|
||||
{"type":"text","text":"deux"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Comportement ACTUEL : seul le premier bloc (`text:"un"`) est émis.
|
||||
assert_eq!(
|
||||
parsed.event,
|
||||
Some(ReplyEvent::TextDelta { text: "un".into() })
|
||||
parsed.events,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "un".into() },
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "Read".into()
|
||||
},
|
||||
ReplyEvent::TextDelta {
|
||||
text: "deux".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -544,10 +592,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parsed.event,
|
||||
Some(ReplyEvent::ToolActivity {
|
||||
parsed.events,
|
||||
vec![ReplyEvent::ToolActivity {
|
||||
label: "outil".into()
|
||||
})
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@ -556,7 +604,7 @@ mod tests {
|
||||
#[test]
|
||||
fn claude_result_without_content_yields_no_event() {
|
||||
let parsed = claude::parse_event(r#"{"type":"result","subtype":"success"}"#).unwrap();
|
||||
assert_eq!(parsed.event, None);
|
||||
assert!(parsed.events.is_empty());
|
||||
}
|
||||
|
||||
/// Ligne whitespace-only (espaces/tabs) ⇒ ignorée comme une ligne vide.
|
||||
@ -569,70 +617,86 @@ mod tests {
|
||||
/// de panic, jamais de Decode.
|
||||
#[test]
|
||||
fn claude_valid_non_object_json_is_ignored() {
|
||||
assert_eq!(claude::parse_event("[1,2,3]").unwrap().event, None);
|
||||
assert_eq!(claude::parse_event("42").unwrap().event, None);
|
||||
assert!(claude::parse_event("[1,2,3]").unwrap().events.is_empty());
|
||||
assert!(claude::parse_event("42").unwrap().events.is_empty());
|
||||
}
|
||||
|
||||
// ---- Codex parse_event (schéma SUPPOSÉ S2 — NON CONFIRMÉ) -----------
|
||||
// NB: toutes ces assertions dépendent du format présumé S2. Le contrat de
|
||||
// sortie (ReplyEvent) est stable ; le mapping changera au spike S2.
|
||||
// ---- Codex parse_event (format RÉEL vérifié 2026-06-09) -------------
|
||||
|
||||
/// [S2 présumé] `tool`/`tool_call` ⇒ ToolActivity ; `name` manquant ⇒ « outil ».
|
||||
/// Un item non-`agent_message` (reasoning/command/…) ⇒ ToolActivity (label = type).
|
||||
#[test]
|
||||
fn codex_tool_activity_and_fallback_s2() {
|
||||
let t = codex::parse_event(r#"{"type":"tool","name":"grep"}"#).unwrap();
|
||||
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!(
|
||||
t.event,
|
||||
Some(ReplyEvent::ToolActivity {
|
||||
label: "grep".into()
|
||||
})
|
||||
r.events,
|
||||
vec![ReplyEvent::ToolActivity {
|
||||
label: "reasoning".into()
|
||||
}]
|
||||
);
|
||||
let t2 = codex::parse_event(r#"{"type":"tool_call"}"#).unwrap();
|
||||
let c = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
t2.event,
|
||||
Some(ReplyEvent::ToolActivity {
|
||||
label: "outil".into()
|
||||
})
|
||||
c.events,
|
||||
vec![ReplyEvent::ToolActivity {
|
||||
label: "command".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// [S2 présumé] `delta` et `message` produisent tous deux un TextDelta.
|
||||
/// `agent_message` ⇒ Final (porte le texte de réponse).
|
||||
#[test]
|
||||
fn codex_delta_and_message_both_text_s2() {
|
||||
let d = codex::parse_event(r#"{"type":"delta","text":"x"}"#).unwrap();
|
||||
let m = codex::parse_event(r#"{"type":"message","text":"y"}"#).unwrap();
|
||||
assert_eq!(d.event, Some(ReplyEvent::TextDelta { text: "x".into() }));
|
||||
assert_eq!(m.event, Some(ReplyEvent::TextDelta { text: "y".into() }));
|
||||
}
|
||||
|
||||
/// [S2 présumé] `final` (alias de `result`) avec repli sur le champ `output`.
|
||||
#[test]
|
||||
fn codex_final_alias_and_output_fallback_s2() {
|
||||
let f = codex::parse_event(r#"{"type":"final","output":"sortie"}"#).unwrap();
|
||||
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!(
|
||||
f.event,
|
||||
Some(ReplyEvent::Final {
|
||||
content: "sortie".into()
|
||||
})
|
||||
m.events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "bonjour".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// [S2 présumé] id de conversation : repli `id` quand `conversation_id` absent.
|
||||
/// `agent_message` sans `text` ⇒ Final avec contenu vide (pas de panic).
|
||||
#[test]
|
||||
fn codex_conversation_id_falls_back_to_id_s2() {
|
||||
let p = codex::parse_event(r#"{"type":"session","id":"cx-7"}"#).unwrap();
|
||||
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_eq!(p.event, None);
|
||||
assert!(p.events.is_empty());
|
||||
}
|
||||
|
||||
/// [S2 présumé] ligne vide / type inconnu ⇒ ignorée sans erreur.
|
||||
/// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur.
|
||||
#[test]
|
||||
fn codex_empty_and_unknown_ignored_s2() {
|
||||
fn codex_empty_and_unknown_ignored() {
|
||||
assert_eq!(codex::parse_event("").unwrap(), Default::default());
|
||||
assert_eq!(
|
||||
codex::parse_event(r#"{"type":"heartbeat"}"#).unwrap().event,
|
||||
None
|
||||
);
|
||||
assert!(codex::parse_event(r#"{"type":"heartbeat"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.started"}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#)
|
||||
.unwrap()
|
||||
.events
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
// ---- Machinerie process via FakeCli ---------------------------------
|
||||
@ -642,9 +706,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn codex_stream_closed_after_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"session","conversation_id":"c"}"#,
|
||||
r#"{"type":"delta","text":"a"}"#,
|
||||
r#"{"type":"result","text":"fin"}"#,
|
||||
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();
|
||||
@ -769,7 +834,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn claude_resume_command_carries_resume_flag() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"result","subtype":"success","result":"ok","session_id":"resume-id"}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"resume-id","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
@ -795,15 +860,26 @@ mod tests {
|
||||
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 <id> <prompt>` (schéma S2 présumé).
|
||||
/// 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_flag_s2() {
|
||||
let (cmd, argv) =
|
||||
make_recording_fake(&[r#"{"type":"result","text":"ok","conversation_id":"cx-id"}"#]);
|
||||
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(),
|
||||
@ -814,8 +890,10 @@ mod tests {
|
||||
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(&"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);
|
||||
@ -856,7 +934,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn factory_resume_seeds_codex() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
let fake = FakeCli::printing(&[r#"{"type":"result","text":"ok"}"#]);
|
||||
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(
|
||||
@ -927,8 +1007,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn codex_contract_holds_with_zero_deltas() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"session","conversation_id":"cx-0"}"#,
|
||||
r#"{"type":"result","text":"direct"}"#,
|
||||
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(),
|
||||
@ -938,4 +1018,113 @@ mod tests {
|
||||
));
|
||||
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![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "T".into() },
|
||||
ReplyEvent::TextDelta { text: "b".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "final-ok".into()
|
||||
},
|
||||
],
|
||||
"le flot complet doit aplatir les 3 blocs 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user