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:
@ -45,15 +45,21 @@ pub struct ParsedLine {
|
||||
///
|
||||
/// Le flux est du **JSONL**. Types réels :
|
||||
///
|
||||
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||
/// - `{"type":"thread.started","thread_id":"<id>"}` /
|
||||
/// `{"type":"codex.thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
|
||||
/// (= id de conversation pour la reprise), **aucun** événement émis.
|
||||
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
|
||||
/// - `{"type":"conversation.item.added","item":{…}}` /
|
||||
/// `{"type":"item.started","item":{…}}` ⇒ progression non terminale `Started`
|
||||
/// pour les items outil/MCP dès que Codex annonce leur existence.
|
||||
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
|
||||
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
|
||||
/// c'est la réponse) ; si `item.type=="error"` ⇒ [`ReplyEvent::Error`] avec message
|
||||
/// extrait de `item.text`, `item.message`, `item.error.message`, `item.error`, puis
|
||||
/// des champs top-level `stderr`/`message`/`error.message`/`error` ; sinon
|
||||
/// (`reasoning`/`command`/autre) ⇒ [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
||||
/// - `{"type":"conversation.item.done","item":{…}}` est traité comme `item.completed`
|
||||
/// (Codex CLI ≥ 0.147).
|
||||
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
|
||||
/// vient de l'`agent_message`, pas de `turn.completed`).
|
||||
///
|
||||
@ -74,7 +80,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
match value.get("type").and_then(Value::as_str) {
|
||||
Some("thread.started") => {
|
||||
Some("thread.started" | "codex.thread.started") => {
|
||||
// Handshake : on ne capte que le thread_id (= id de conversation).
|
||||
conversation_id = value
|
||||
.get("thread_id")
|
||||
@ -106,15 +112,37 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
});
|
||||
events.push(ReplyEvent::Heartbeat);
|
||||
}
|
||||
Some("item.completed") => {
|
||||
Some("item.started" | "conversation.item.added" | "response.output_item.added") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
append_item_progress(
|
||||
&mut events,
|
||||
item,
|
||||
ReplyProgressStage::Started,
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("item.started"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("item.updated" | "conversation.item.updated") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
append_item_progress(
|
||||
&mut events,
|
||||
item,
|
||||
ReplyProgressStage::Delta,
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("item.updated"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("item.completed" | "conversation.item.done" | "response.output_item.done") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
match item_type(item).as_deref() {
|
||||
Some("agent_message") => {
|
||||
let content = item
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let content = item_text(item).unwrap_or_default();
|
||||
events.push(ReplyEvent::Final { content });
|
||||
}
|
||||
Some("error") => {
|
||||
@ -124,18 +152,18 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
}
|
||||
// reasoning / command / tout autre item ⇒ activité (label = type).
|
||||
Some(kind) => {
|
||||
let label = item_label(item).unwrap_or_else(|| kind.to_owned());
|
||||
events.push(ReplyEvent::Progress {
|
||||
progress: provider_progress(
|
||||
ReplyProgressKind::Tool,
|
||||
progress: item_progress(
|
||||
&label,
|
||||
ReplyProgressStage::Completed,
|
||||
kind,
|
||||
"item.completed",
|
||||
)
|
||||
.with_tool_name(kind.to_owned()),
|
||||
});
|
||||
events.push(ReplyEvent::ToolActivity {
|
||||
label: kind.to_owned(),
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("item.completed"),
|
||||
),
|
||||
});
|
||||
events.push(ReplyEvent::ToolActivity { label });
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@ -161,6 +189,83 @@ fn provider_progress(
|
||||
.with_provider_event("codex", native_event)
|
||||
}
|
||||
|
||||
fn item_progress(
|
||||
label: &str,
|
||||
stage: ReplyProgressStage,
|
||||
native_event: impl Into<String>,
|
||||
) -> ReplyProgress {
|
||||
if label.starts_with("idea_") {
|
||||
ReplyProgress::new(
|
||||
ReplyProgressSource::IdeaLocal,
|
||||
ReplyProgressKind::Mcp,
|
||||
stage,
|
||||
label.to_owned(),
|
||||
)
|
||||
.with_provider_event("codex", native_event)
|
||||
.with_tool_name(label.to_owned())
|
||||
} else {
|
||||
provider_progress(
|
||||
ReplyProgressKind::Tool,
|
||||
stage,
|
||||
label.to_owned(),
|
||||
native_event,
|
||||
)
|
||||
.with_tool_name(label.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn append_item_progress(
|
||||
events: &mut Vec<ReplyEvent>,
|
||||
item: &Value,
|
||||
stage: ReplyProgressStage,
|
||||
native_event: &str,
|
||||
) {
|
||||
let Some(kind) = item_type(item) else {
|
||||
return;
|
||||
};
|
||||
if matches!(kind.as_str(), "agent_message" | "error") {
|
||||
return;
|
||||
}
|
||||
let label = item_label(item).unwrap_or(kind);
|
||||
events.push(ReplyEvent::Progress {
|
||||
progress: item_progress(&label, stage, native_event),
|
||||
});
|
||||
if stage == ReplyProgressStage::Started {
|
||||
events.push(ReplyEvent::ToolActivity { label });
|
||||
}
|
||||
}
|
||||
|
||||
fn item_type(item: &Value) -> Option<String> {
|
||||
non_blank_string(item.get("type"))
|
||||
.or_else(|| non_blank_string(item.get("item_type")))
|
||||
.or_else(|| non_blank_string(item.get("kind")))
|
||||
}
|
||||
|
||||
fn item_label(item: &Value) -> Option<String> {
|
||||
non_blank_string(item.get("name"))
|
||||
.or_else(|| non_blank_string(item.get("tool_name")))
|
||||
.or_else(|| non_blank_string(item.get("tool")))
|
||||
.or_else(|| non_blank_string(item.get("action")))
|
||||
.or_else(|| nested_non_blank(item, &["call", "name"]))
|
||||
.or_else(|| nested_non_blank(item, &["function", "name"]))
|
||||
.or_else(|| nested_non_blank(item, &["function_call", "name"]))
|
||||
.or_else(|| item_type(item))
|
||||
}
|
||||
|
||||
fn item_text(item: &Value) -> Option<String> {
|
||||
non_blank_string(item.get("text"))
|
||||
.or_else(|| non_blank_string(item.get("message")))
|
||||
.or_else(|| nested_non_blank(item, &["content", "text"]))
|
||||
}
|
||||
|
||||
fn nested_non_blank(root: &Value, path: &[&str]) -> Option<String> {
|
||||
let mut cursor = root;
|
||||
for key in path {
|
||||
cursor = cursor.get(*key)?;
|
||||
}
|
||||
non_blank_string(Some(cursor))
|
||||
}
|
||||
|
||||
const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur.";
|
||||
|
||||
fn non_blank_string(value: Option<&Value>) -> Option<String> {
|
||||
|
||||
Reference in New Issue
Block a user