diff --git a/frontend/src/features/tickets/useTicketAssistant.test.tsx b/frontend/src/features/tickets/useTicketAssistant.test.tsx index 04ef025b..4f014363 100644 --- a/frontend/src/features/tickets/useTicketAssistant.test.tsx +++ b/frontend/src/features/tickets/useTicketAssistant.test.tsx @@ -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 () => { diff --git a/frontend/src/features/tickets/useTicketAssistant.ts b/frontend/src/features/tickets/useTicketAssistant.ts index 029cbb97..16c5852a 100644 --- a/frontend/src/features/tickets/useTicketAssistant.ts +++ b/frontend/src/features/tickets/useTicketAssistant.ts @@ -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") {