merge(chat): réponse du modèle non finalisée sur long message — #239 (QA verte)
This commit is contained in:
@ -172,6 +172,28 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_text_fallback_final(events: &mut Vec<ReplyEvent>) {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut has_final = false;
|
||||||
|
let mut has_error = false;
|
||||||
|
let mut has_rate_limit = false;
|
||||||
|
for event in events.iter() {
|
||||||
|
match event {
|
||||||
|
ReplyEvent::TextDelta { text: delta } => text.push_str(delta),
|
||||||
|
ReplyEvent::Final { .. } => has_final = true,
|
||||||
|
ReplyEvent::Error { .. } => has_error = true,
|
||||||
|
ReplyEvent::RateLimited { .. } => has_rate_limit = true,
|
||||||
|
ReplyEvent::Progress { .. }
|
||||||
|
| ReplyEvent::ToolActivity { .. }
|
||||||
|
| ReplyEvent::Heartbeat
|
||||||
|
| ReplyEvent::Announcement { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_final && !has_error && !has_rate_limit && !text.trim().is_empty() {
|
||||||
|
events.push(ReplyEvent::Final { content: text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_use_preview(block: &Value) -> Option<String> {
|
fn tool_use_preview(block: &Value) -> Option<String> {
|
||||||
block
|
block
|
||||||
.get("input")
|
.get("input")
|
||||||
@ -424,7 +446,76 @@ impl ClaudeSdkSession {
|
|||||||
if let Some(id) = captured_id {
|
if let Some(id) = captured_id {
|
||||||
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
*self.conversation_id.lock().expect("mutex sain") = Some(id);
|
||||||
}
|
}
|
||||||
|
append_text_fallback_final(&mut events);
|
||||||
|
|
||||||
Ok(Box::new(events.into_iter()))
|
Ok(Box::new(events.into_iter()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assistant_line(text: &str) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{ "type": "text", "text": text }]
|
||||||
|
},
|
||||||
|
"session_id": "session-1"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_only_success_stream_gets_fallback_final() {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for line in [assistant_line("longue "), assistant_line("réponse")] {
|
||||||
|
events.extend(parse_event(&line).expect("valid assistant line").events);
|
||||||
|
}
|
||||||
|
|
||||||
|
append_text_fallback_final(&mut events);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
events.last(),
|
||||||
|
Some(&ReplyEvent::Final {
|
||||||
|
content: "longue réponse".to_owned()
|
||||||
|
}),
|
||||||
|
"assistant text must remain usable when Claude omits a result line"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_final_does_not_mask_rate_limit_or_error() {
|
||||||
|
let mut limited = vec![
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "partiel".to_owned(),
|
||||||
|
},
|
||||||
|
ReplyEvent::RateLimited { resets_at_ms: None },
|
||||||
|
];
|
||||||
|
append_text_fallback_final(&mut limited);
|
||||||
|
assert!(
|
||||||
|
limited
|
||||||
|
.iter()
|
||||||
|
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
|
||||||
|
"rate-limited streams must remain non-final"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut failed = vec![
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "partiel".to_owned(),
|
||||||
|
},
|
||||||
|
ReplyEvent::Error {
|
||||||
|
message: "provider error".to_owned(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
append_text_fallback_final(&mut failed);
|
||||||
|
assert!(
|
||||||
|
failed
|
||||||
|
.iter()
|
||||||
|
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
|
||||||
|
"provider errors must not be converted to successful finals"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1513,11 +1513,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
/// Régression #239 : Claude peut fermer proprement après des blocs `assistant`
|
||||||
/// niveau de l'adapter — `send()` renvoie Ok avec uniquement des deltas et AUCUN
|
/// textuels sans ligne `result`. Ces deltas sont une réponse modèle exploitable :
|
||||||
/// `Final`. Le §17.9 D2 mentionne « flux sans Final ⇒ Io » ; l'adapter actuel ne
|
/// l'adapter doit donc fabriquer un `Final` de secours, au lieu de laisser la
|
||||||
/// l'applique pas (c'est `send_blocking` côté application, lot D1, qui transforme
|
/// surface chat conclure à tort « réponse non finalisée ».
|
||||||
/// l'absence de Final en Timeout). Ce test PINNE le comportement réel observé.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stream_without_final_is_silently_ok_at_adapter_level() {
|
async fn stream_without_final_is_silently_ok_at_adapter_level() {
|
||||||
let fake = FakeCli::printing(&[
|
let fake = FakeCli::printing(&[
|
||||||
@ -1533,11 +1532,18 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||||
let finals = events
|
let finals: Vec<_> = events
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
.filter_map(|e| match e {
|
||||||
.count();
|
ReplyEvent::Final { content } => Some(content.as_str()),
|
||||||
assert_eq!(finals, 0, "comportement actuel: aucun Final fabriqué");
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
finals,
|
||||||
|
vec!["a"],
|
||||||
|
"Claude text-only streams must yield one fallback Final"
|
||||||
|
);
|
||||||
// L'id de conversation est tout de même capté (init lu).
|
// L'id de conversation est tout de même capté (init lu).
|
||||||
assert_eq!(s.conversation_id().as_deref(), Some("c"));
|
assert_eq!(s.conversation_id().as_deref(), Some("c"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,32 @@ describe("useTicketAssistant — ticket #60 (no-reply terminal)", () => {
|
|||||||
expect(last.role).toBe("assistant");
|
expect(last.role).toBe("assistant");
|
||||||
expect(last.pending).toBeFalsy();
|
expect(last.pending).toBeFalsy();
|
||||||
expect(last.text).toContain("Le modèle n'a renvoyé aucune réponse.");
|
expect(last.text).toContain("Le modèle n'a renvoyé aucune réponse.");
|
||||||
|
expect(last.text).toContain("Échec sans contenu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves streamed partial content when the terminal error says finalization failed", async () => {
|
||||||
|
const gw = new FakeTicketGateway();
|
||||||
|
const { result } = await openAndSend(gw);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
gw.captured!({ kind: "textDelta", text: "Réponse partielle utile." });
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
gw.captured!({
|
||||||
|
kind: "error",
|
||||||
|
message: "La réponse du modèle n'a pas pu être finalisée.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.busy).toBe(false));
|
||||||
|
const last = result.current.turns.at(-1)!;
|
||||||
|
expect(last.role).toBe("assistant");
|
||||||
|
expect(last.pending).toBeFalsy();
|
||||||
|
expect(last.text).toContain("Réponse partielle utile.");
|
||||||
|
expect(last.text).toContain("Réponse partielle — fin non reçue.");
|
||||||
|
expect(last.text).toContain(
|
||||||
|
"demandez à l'assistant de continuer à partir de la réponse partielle",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a non-empty final renders the answer and clears busy (nominal, no regression)", async () => {
|
it("a non-empty final renders the answer and clears busy (nominal, no regression)", async () => {
|
||||||
|
|||||||
@ -52,6 +52,31 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
return String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assistantFailureText(message: string, partialText: string): string {
|
||||||
|
const partial = partialText.trim();
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
if (!partial) {
|
||||||
|
return [
|
||||||
|
`Échec sans contenu — ${message}`,
|
||||||
|
"Reprise: renvoyez le message ou reformulez-le en plusieurs parties.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
normalized.includes("tronqu") || normalized.includes("truncat")
|
||||||
|
? "Réponse tronquée."
|
||||||
|
: normalized.includes("finalis") || normalized.includes("fin ")
|
||||||
|
? "Réponse partielle — fin non reçue."
|
||||||
|
: "Réponse partielle — échec transitoire possible.";
|
||||||
|
return [
|
||||||
|
partialText,
|
||||||
|
"",
|
||||||
|
`Échec — ${label}`,
|
||||||
|
message,
|
||||||
|
"Reprise: demandez à l'assistant de continuer à partir de la réponse partielle ci-dessus.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function useTicketAssistant(
|
export function useTicketAssistant(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
issueRef: string,
|
issueRef: string,
|
||||||
@ -136,12 +161,11 @@ export function useTicketAssistant(
|
|||||||
// Best-effort inline activity badge.
|
// Best-effort inline activity badge.
|
||||||
next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` };
|
next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` };
|
||||||
} else if (chunk.kind === "error") {
|
} else if (chunk.kind === "error") {
|
||||||
// Terminal fallback (ticket #60): the model returned no usable answer.
|
// Terminal fallback: preserve any streamed content, then classify the
|
||||||
// Freeze the turn with the readable explanation so the bubble is never
|
// failure so a partial answer is not hidden by the terminal error.
|
||||||
// left empty and pending — the assistant no longer hangs silently.
|
|
||||||
next[i] = {
|
next[i] = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
text: `⚠️ ${chunk.message}`,
|
text: assistantFailureText(chunk.message, next[i].text),
|
||||||
pending: false,
|
pending: false,
|
||||||
};
|
};
|
||||||
} else if (chunk.kind === "final") {
|
} else if (chunk.kind === "final") {
|
||||||
|
|||||||
Reference in New Issue
Block a user