fix(chat): le contenu partiel streamé n'est plus masqué par l'erreur terminale de finalisation — #239

assistantFailureText conserve le texte déjà streamé puis classe l'échec
(sans contenu, tronqué, fin non reçue, transitoire) avec conseil de reprise
adapté, au lieu d'écraser la bulle par « Échec — … finalisée. ».
This commit is contained in:
2026-08-22 16:38:55 +02:00
parent ade1908c76
commit cbfdfeb356
2 changed files with 54 additions and 4 deletions

View File

@ -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 () => {

View File

@ -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") {