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.pending).toBeFalsy();
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 () => {

View File

@ -52,6 +52,31 @@ function describe(e: unknown): string {
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(
projectId: string,
issueRef: string,
@ -136,12 +161,11 @@ export function useTicketAssistant(
// Best-effort inline activity badge.
next[i] = { ...next[i], text: `${next[i].text}\n· ${chunk.label}` };
} else if (chunk.kind === "error") {
// Terminal fallback (ticket #60): the model returned no usable answer.
// Freeze the turn with the readable explanation so the bubble is never
// left empty and pending — the assistant no longer hangs silently.
// Terminal fallback: preserve any streamed content, then classify the
// failure so a partial answer is not hidden by the terminal error.
next[i] = {
role: "assistant",
text: `⚠️ ${chunk.message}`,
text: assistantFailureText(chunk.message, next[i].text),
pending: false,
};
} else if (chunk.kind === "final") {