Merge feature/ticket60-ticket-assistant-no-reply into develop (#60)
Rendre visible l'échec « aucune réponse » de l'assistant de ticket : stream sans Final ou Final vide → ReplyChunk::Error terminal visible, plus jamais de tour muet. Backend + frontend, tests verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -47,8 +47,10 @@ pub struct ParsedLine {
|
||||
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
|
||||
/// - `{"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) ; sinon (`reasoning`/`command`/autre) ⇒
|
||||
/// [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
|
||||
/// 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":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
|
||||
/// vient de l'`agent_message`, pas de `turn.completed`).
|
||||
///
|
||||
@ -91,6 +93,11 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
.to_owned();
|
||||
events.push(ReplyEvent::Final { content });
|
||||
}
|
||||
Some("error") => {
|
||||
events.push(ReplyEvent::Error {
|
||||
message: codex_error_message(&value, item),
|
||||
});
|
||||
}
|
||||
// reasoning / command / tout autre item ⇒ activité (label = type).
|
||||
Some(kind) => events.push(ReplyEvent::ToolActivity {
|
||||
label: kind.to_owned(),
|
||||
@ -109,6 +116,31 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
||||
})
|
||||
}
|
||||
|
||||
const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur.";
|
||||
|
||||
fn non_blank_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn error_value_message(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?;
|
||||
non_blank_string(value.get("message")).or_else(|| non_blank_string(Some(value)))
|
||||
}
|
||||
|
||||
fn codex_error_message(root: &Value, item: &Value) -> String {
|
||||
non_blank_string(item.get("text"))
|
||||
.or_else(|| non_blank_string(item.get("message")))
|
||||
.or_else(|| error_value_message(item.get("error")))
|
||||
.or_else(|| non_blank_string(root.get("stderr")))
|
||||
.or_else(|| non_blank_string(root.get("message")))
|
||||
.or_else(|| error_value_message(root.get("error")))
|
||||
.unwrap_or_else(|| CODEX_ERROR_FALLBACK.to_owned())
|
||||
}
|
||||
|
||||
/// Adapter de session structurée Codex.
|
||||
///
|
||||
/// Incarnation « un `codex exec` par tour » (§17.2 (b)) : chaque `send` relance
|
||||
|
||||
@ -887,6 +887,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `item.type == error` ⇒ Error terminal visible, avec message réel.
|
||||
#[test]
|
||||
fn codex_error_item_is_error_event_with_real_message() {
|
||||
let r = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"error","text":"auth failed"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.events,
|
||||
vec![ReplyEvent::Error {
|
||||
message: "auth failed".into()
|
||||
}]
|
||||
);
|
||||
|
||||
let nested = codex::parse_event(
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"error","error":{"message":"quota exceeded"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
nested.events,
|
||||
vec![ReplyEvent::Error {
|
||||
message: "quota exceeded".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// `item.type == error` sans champ exploitable ⇒ fallback stable.
|
||||
#[test]
|
||||
fn codex_error_item_without_message_uses_fallback() {
|
||||
let r =
|
||||
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i0","type":"error"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.events,
|
||||
vec![ReplyEvent::Error {
|
||||
message: "Codex a renvoyé une erreur.".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// `agent_message` ⇒ Final (porte le texte de réponse).
|
||||
#[test]
|
||||
fn codex_agent_message_is_final() {
|
||||
@ -982,6 +1022,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_error_item_surfaces_error_without_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||
r#"{"type":"turn.started"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"error","text":"auth failed"}}"#,
|
||||
r#"{"type":"turn.completed","usage":{}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
ReplyEvent::Error { message } if message.contains("auth failed")
|
||||
)));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
|
||||
"Codex error must not be fabricated as a successful Final"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_two_agent_messages_preserve_first_as_announcement_and_last_as_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
|
||||
@ -38,6 +38,8 @@ pub struct OpenAiToolCall {
|
||||
pub struct ParsedChatDelta {
|
||||
/// Fragment de texte assistant.
|
||||
pub text: String,
|
||||
/// Fragment de raisonnement exposé par certains serveurs OpenAI-compatibles.
|
||||
pub reasoning_content: String,
|
||||
/// Appels d'outils annoncés par ce chunk.
|
||||
pub tool_calls: Vec<OpenAiToolCall>,
|
||||
}
|
||||
@ -47,13 +49,26 @@ pub struct ParsedChatDelta {
|
||||
pub struct ParsedCompletion {
|
||||
/// Contenu assistant complet.
|
||||
pub content: String,
|
||||
/// Contenu alternatif de raisonnement, quand le serveur laisse `content` vide.
|
||||
pub reasoning_content: String,
|
||||
/// Appels d'outils demandés par le modèle.
|
||||
pub tool_calls: Vec<OpenAiToolCall>,
|
||||
}
|
||||
|
||||
impl ParsedCompletion {
|
||||
fn visible_content(&self) -> String {
|
||||
if self.content.trim().is_empty() {
|
||||
self.reasoning_content.clone()
|
||||
} else {
|
||||
self.content.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct StreamState {
|
||||
content: String,
|
||||
reasoning_content: String,
|
||||
tool_calls: Vec<ToolCallBuilder>,
|
||||
}
|
||||
|
||||
@ -108,8 +123,18 @@ pub fn parse_chat_delta(line: &str) -> Result<ParsedChatDelta, AgentSessionError
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let reasoning_content = delta
|
||||
.get("reasoning_content")
|
||||
.or_else(|| delta.get("reasoningContent"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let tool_calls = parse_tool_calls_array(delta.get("tool_calls"));
|
||||
Ok(ParsedChatDelta { text, tool_calls })
|
||||
Ok(ParsedChatDelta {
|
||||
text,
|
||||
reasoning_content,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse une réponse non-streaming `/chat/completions`.
|
||||
@ -130,12 +155,20 @@ pub fn parse_completion(body: &str) -> Result<ParsedCompletion, AgentSessionErro
|
||||
"chat completion schema missing choices[0].message".to_owned(),
|
||||
));
|
||||
};
|
||||
let content = message
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let reasoning_content = message
|
||||
.get("reasoning_content")
|
||||
.or_else(|| message.get("reasoningContent"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
Ok(ParsedCompletion {
|
||||
content: message
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned(),
|
||||
content,
|
||||
reasoning_content,
|
||||
tool_calls: parse_tool_calls_array(message.get("tool_calls")),
|
||||
})
|
||||
}
|
||||
@ -264,6 +297,7 @@ impl OpenAiCompatibleSession {
|
||||
};
|
||||
|
||||
let turn = self.consume_response(response, &tap).await?;
|
||||
let visible_content = turn.visible_content();
|
||||
events.extend(turn.events);
|
||||
|
||||
if !turn.tool_calls.is_empty() {
|
||||
@ -286,9 +320,9 @@ impl OpenAiCompatibleSession {
|
||||
self.transcript
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.push(json!({"role": "assistant", "content": turn.content}));
|
||||
.push(json!({"role": "assistant", "content": visible_content}));
|
||||
events.push(ReplyEvent::Final {
|
||||
content: turn.content,
|
||||
content: visible_content,
|
||||
});
|
||||
self.persist_transcript()?;
|
||||
return Ok(Box::new(events.into_iter()));
|
||||
@ -362,9 +396,11 @@ impl OpenAiCompatibleSession {
|
||||
.await
|
||||
.map_err(|e| AgentSessionError::Io(format!("lecture réponse HTTP: {e}")))?;
|
||||
let parsed = parse_completion(&body)?;
|
||||
let visible_content = parsed.visible_content();
|
||||
return Ok(TurnResult {
|
||||
events: text_events(&parsed.content, tap),
|
||||
events: text_events(&visible_content, tap),
|
||||
content: parsed.content,
|
||||
reasoning_content: parsed.reasoning_content,
|
||||
tool_calls: parsed.tool_calls,
|
||||
});
|
||||
}
|
||||
@ -389,6 +425,7 @@ impl OpenAiCompatibleSession {
|
||||
Ok(TurnResult {
|
||||
events,
|
||||
content: state.content,
|
||||
reasoning_content: state.reasoning_content,
|
||||
tool_calls: state
|
||||
.tool_calls
|
||||
.into_iter()
|
||||
@ -511,9 +548,20 @@ impl AgentSession for OpenAiCompatibleSession {
|
||||
struct TurnResult {
|
||||
events: Vec<ReplyEvent>,
|
||||
content: String,
|
||||
reasoning_content: String,
|
||||
tool_calls: Vec<OpenAiToolCall>,
|
||||
}
|
||||
|
||||
impl TurnResult {
|
||||
fn visible_content(&self) -> String {
|
||||
if self.content.trim().is_empty() {
|
||||
self.reasoning_content.clone()
|
||||
} else {
|
||||
self.content.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_sse_line(
|
||||
line: &str,
|
||||
state: &mut StreamState,
|
||||
@ -528,12 +576,23 @@ fn handle_sse_line(
|
||||
return Ok(());
|
||||
}
|
||||
let parsed = parse_chat_delta(data)?;
|
||||
if !parsed.text.is_empty() {
|
||||
let has_text = !parsed.text.is_empty();
|
||||
if has_text {
|
||||
state.content.push_str(&parsed.text);
|
||||
let event = ReplyEvent::TextDelta { text: parsed.text };
|
||||
send_tap(tap, &event);
|
||||
events.push(event);
|
||||
}
|
||||
if !has_text && !parsed.reasoning_content.is_empty() {
|
||||
state.reasoning_content.push_str(&parsed.reasoning_content);
|
||||
let event = ReplyEvent::TextDelta {
|
||||
text: parsed.reasoning_content,
|
||||
};
|
||||
send_tap(tap, &event);
|
||||
events.push(event);
|
||||
} else if !parsed.reasoning_content.is_empty() {
|
||||
state.reasoning_content.push_str(&parsed.reasoning_content);
|
||||
}
|
||||
merge_tool_call_deltas(state, parsed.tool_calls);
|
||||
Ok(())
|
||||
}
|
||||
@ -802,15 +861,82 @@ mod tests {
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert_eq!(parsed.text, "bonjour");
|
||||
assert!(parsed.reasoning_content.is_empty());
|
||||
assert!(parsed.tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_openai_sse_delta_reasoning_content() {
|
||||
let parsed = parse_chat_delta(
|
||||
r#"{"choices":[{"delta":{"reasoning_content":"je raisonne"},"finish_reason":null}]}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert!(parsed.text.is_empty());
|
||||
assert_eq!(parsed.reasoning_content, "je raisonne");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_completion_message() {
|
||||
let parsed =
|
||||
parse_completion(r#"{"choices":[{"message":{"role":"assistant","content":"salut"}}]}"#)
|
||||
.expect("parse ok");
|
||||
assert_eq!(parsed.content, "salut");
|
||||
assert!(parsed.reasoning_content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_completion_reasoning_content() {
|
||||
let parsed = parse_completion(
|
||||
r#"{"choices":[{"message":{"role":"assistant","content":"","reasoning_content":"raisonnement visible"}}]}"#,
|
||||
)
|
||||
.expect("parse ok");
|
||||
assert!(parsed.content.is_empty());
|
||||
assert_eq!(parsed.reasoning_content, "raisonnement visible");
|
||||
assert_eq!(parsed.visible_content(), "raisonnement visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_done_without_content_leaves_empty_visible_content_for_chat_error_mapping() {
|
||||
let mut state = StreamState::default();
|
||||
let mut events = Vec::new();
|
||||
handle_sse_line("data: [DONE]", &mut state, &mut events, &None).expect("handle done");
|
||||
let turn = TurnResult {
|
||||
events,
|
||||
content: state.content,
|
||||
reasoning_content: state.reasoning_content,
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
assert!(turn.events.is_empty());
|
||||
assert!(
|
||||
turn.visible_content().is_empty(),
|
||||
"chat bridge maps this empty final to ReplyChunk::Error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_reasoning_content_is_surfaced_when_content_is_empty_without_http() {
|
||||
let mut state = StreamState::default();
|
||||
let mut events = Vec::new();
|
||||
handle_sse_line(
|
||||
r#"data: {"choices":[{"delta":{"reasoning_content":"raison"},"finish_reason":null}]}"#,
|
||||
&mut state,
|
||||
&mut events,
|
||||
&None,
|
||||
)
|
||||
.expect("handle reasoning");
|
||||
let turn = TurnResult {
|
||||
events,
|
||||
content: state.content,
|
||||
reasoning_content: state.reasoning_content,
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
turn.events.as_slice(),
|
||||
[ReplyEvent::TextDelta { text }] if text == "raison"
|
||||
));
|
||||
assert_eq!(turn.visible_content(), "raison");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user