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:
@ -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 () => {
|
||||
|
||||
@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user