fix(codex): activité tool/MCP émise en live pendant le tour (#58 #93 #171)

L'adaptateur Codex n'émettait Progress/ToolActivity qu'à item.completed. On capte désormais conversation.item.added / item.started / response.output_item.added (stage Started), item.updated (Delta) et conversation.item.done / response.output_item.done (Completed), en préservant le nom réel de l'outil (name / function.name / function_call.name).

Les outils idea_* sont taggés ReplyProgressSource::IdeaLocal / Kind::Mcp pour les distinguer du tooling provider. Corrige #93 (l'activité MCP idea_ask_agent devient visible avant le Final, nom non corrompu) et #171 (pipeline progress -> UI temps réel) ; base du rendu live #58.

Tests : 3 unitaires (started live, tag IdeaLocal/Mcp, done avec nom) + assertion d'intégration (MCP started tapé avant le Final). QA : verte (live).
This commit is contained in:
2026-08-10 16:46:17 +02:00
parent 47bc2c4fb6
commit c5a38df2af
2 changed files with 219 additions and 19 deletions

View File

@ -50,7 +50,8 @@ mod tests {
use domain::ids::ProfileId;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
PreparedContext, ReplyEvent, SessionPlan,
PreparedContext, ReplyEvent, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
SessionPlan,
};
use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter};
use domain::project::ProjectPath;
@ -965,6 +966,88 @@ mod tests {
);
}
#[test]
fn codex_conversation_item_added_emits_live_started_progress() {
let r = codex::parse_event(
r#"{"type":"conversation.item.added","item":{"id":"call_1","type":"function_call","name":"read_file","arguments":"{}"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::ProviderNative
&& progress.kind == ReplyProgressKind::Tool
&& progress.stage == ReplyProgressStage::Started
&& progress.label == "read_file"
&& progress.tool_name.as_deref() == Some("read_file")
&& progress.native_event.as_deref() == Some("conversation.item.added")
),
"Codex item.added must surface tool start immediately: {:?}",
r.events
);
assert!(
r.events.iter().any(
|event| matches!(event, ReplyEvent::ToolActivity { label } if label == "read_file")
),
"tool activity remains available for existing UI mapping"
);
}
#[test]
fn codex_idea_tool_is_tagged_as_local_mcp_progress() {
let r = codex::parse_event(
r#"{"type":"conversation.item.added","item":{"id":"call_1","type":"function_call","name":"idea_ask_agent","arguments":"{}"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.label == "idea_ask_agent"
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
&& progress.native_event.as_deref() == Some("conversation.item.added")
),
"Codex MCP tool start must be tagged as IdeaLocal/Mcp: {:?}",
r.events
);
}
#[test]
fn codex_conversation_item_done_completes_tool_with_name() {
let r = codex::parse_event(
r#"{"type":"conversation.item.done","item":{"id":"call_1","type":"function_call","name":"idea_ask_agent","status":"completed"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Completed
&& progress.label == "idea_ask_agent"
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
&& progress.native_event.as_deref() == Some("conversation.item.done")
),
"Codex MCP tool completion must keep the real tool name: {:?}",
r.events
);
assert!(
matches!(
r.events.get(1),
Some(ReplyEvent::ToolActivity { label }) if label == "idea_ask_agent"
),
"completion still emits ToolActivity compatibility event"
);
}
/// `item.type == error` ⇒ Error terminal visible, avec message réel.
#[test]
fn codex_error_item_is_error_event_with_real_message() {
@ -1220,6 +1303,7 @@ mod tests {
let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#,
r#"{"type":"turn.started"}"#,
r#"{"type":"conversation.item.added","item":{"id":"tool0","type":"function_call","name":"idea_ask_agent","arguments":"{}"}}"#,
r#"{"type":"item.completed","item":{"id":"tool0","type":"command","text":"cargo test"}}"#,
r#"{"type":"turn.completed"}"#,
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
@ -1243,6 +1327,17 @@ mod tests {
matches!(live.first(), Some(ReplyEvent::Progress { progress }) if progress.native_event.as_deref() == Some("turn.started")),
"le démarrage du tour provider doit être tapé live en premier"
);
assert!(
live.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)),
"le début MCP Codex doit être tapé live avant le Final"
);
assert!(
live.iter().any(
|event| matches!(event, ReplyEvent::ToolActivity { label } if label == "command")