fix(ticket-assistant): rendre visible l'échec « aucune réponse » de l'assistant de ticket (#60)
Introduit un variant terminal `ReplyEvent::Error { message }` (domain/ports)
propagé jusqu'au chat : l'adapter OpenAI-compat récupère `reasoning_content`
quand `content` est vide, et un `Final` vide est converti en `Error` visible
plutôt qu'un tour silencieux qui pend. Le front (`ReplyChunk` + useTicketAssistant)
rend ce message d'erreur dans la cellule.
- domain: variant `ReplyEvent::Error`, bras non terminal (readiness, structured drain)
- infra/session: parse `reasoning_content` (delta/completion), fallback visible
- app-tauri: mapping ReplyEvent::Error -> ReplyChunk::Error, Final vide -> Error
- frontend: ReplyChunk `error`, rendu dans useTicketAssistant (+ test .test.tsx)
NB: commit sur la feature branch, PAS un merge. #60 reste inProgress.
Les 10 tests `cargo test -p infrastructure --lib session` échouent SOUS SANDBOX
uniquement (bind loopback 127.0.0.1:0 interdit -> Os PermissionDenied), pas une
régression : revérification hors-sandbox requise avant tout merge vers develop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1221,9 +1221,12 @@ export interface TicketChat {
|
||||
* One streamed chunk of an assistant turn (mirror of the backend `ReplyChunk`,
|
||||
* tagged on `kind`). `textDelta` is an incremental text fragment; `toolActivity`
|
||||
* a best-effort activity badge; `final` the deterministic end-of-turn chunk
|
||||
* carrying the aggregated content — after it the turn is frozen.
|
||||
* carrying the aggregated content — after it the turn is frozen. `error` is a
|
||||
* visible terminal fallback (ticket #60): the model produced no usable answer,
|
||||
* so the turn ends with a human-readable explanation instead of a silent hang.
|
||||
*/
|
||||
export type ReplyChunk =
|
||||
| { kind: "textDelta"; text: string }
|
||||
| { kind: "toolActivity"; label: string }
|
||||
| { kind: "final"; content: string };
|
||||
| { kind: "final"; content: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
140
frontend/src/features/tickets/useTicketAssistant.test.tsx
Normal file
140
frontend/src/features/tickets/useTicketAssistant.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Ticket #60 — the ticket AI assistant must never hang silently.
|
||||
*
|
||||
* The backend now emits an explicit terminal `{ kind: "error", message }` chunk
|
||||
* when a turn produces no usable answer. This hook must consume it: end the
|
||||
* pending turn with a readable message and drop `busy`. It must also keep the
|
||||
* input disabled until the REAL terminal (`final` or `error`) arrives — not the
|
||||
* moment `agent_send` returns (the stream is still flowing then).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { ReplyChunk, TicketChat } from "@/domain";
|
||||
import { useTicketAssistant } from "./useTicketAssistant";
|
||||
|
||||
const P = "proj";
|
||||
const REF = "#1";
|
||||
|
||||
/**
|
||||
* A controllable ticket gateway: `sendTicketChat` captures the chunk handler and
|
||||
* stays pending until the test resolves it, so the terminal chunk timing is
|
||||
* driven explicitly (no reliance on the transport promise settling).
|
||||
*/
|
||||
class FakeTicketGateway {
|
||||
captured: ((chunk: ReplyChunk) => void) | null = null;
|
||||
sendResolve: (() => void) | null = null;
|
||||
sendCalls = 0;
|
||||
|
||||
async openTicketChat(): Promise<TicketChat> {
|
||||
return { sessionId: "sess-1", requester: "a1", issueRef: REF } as TicketChat;
|
||||
}
|
||||
async closeTicketChat(): Promise<void> {}
|
||||
sendTicketChat(
|
||||
_sessionId: string,
|
||||
_message: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
this.sendCalls += 1;
|
||||
this.captured = onChunk;
|
||||
return new Promise<void>((resolve) => {
|
||||
this.sendResolve = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wrapperFor(ticket: FakeTicketGateway) {
|
||||
const gateways = { ticket } as unknown as Gateways;
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <DIProvider gateways={gateways}>{children}</DIProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the session and fires a send that leaves the transport pending. */
|
||||
async function openAndSend(gw: FakeTicketGateway) {
|
||||
const hook = renderHook(() => useTicketAssistant(P, REF), {
|
||||
wrapper: wrapperFor(gw),
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.open("prof-1");
|
||||
});
|
||||
expect(hook.result.current.sessionId).toBe("sess-1");
|
||||
act(() => {
|
||||
void hook.result.current.send("édite le ticket");
|
||||
});
|
||||
await waitFor(() => expect(gw.captured).toBeTruthy());
|
||||
return hook;
|
||||
}
|
||||
|
||||
describe("useTicketAssistant — ticket #60 (no-reply terminal)", () => {
|
||||
it("an error chunk ends the pending turn with a visible message and clears busy", async () => {
|
||||
const gw = new FakeTicketGateway();
|
||||
const { result } = await openAndSend(gw);
|
||||
|
||||
// Mid-turn: busy true, an empty pending assistant bubble is streaming.
|
||||
expect(result.current.busy).toBe(true);
|
||||
expect(result.current.turns.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
pending: true,
|
||||
});
|
||||
|
||||
// Backend terminal fallback: no usable answer.
|
||||
act(() => {
|
||||
gw.captured!({
|
||||
kind: "error",
|
||||
message: "Le modèle n'a renvoyé aucune réponse.",
|
||||
});
|
||||
});
|
||||
|
||||
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("Le modèle n'a renvoyé aucune réponse.");
|
||||
});
|
||||
|
||||
it("a non-empty final renders the answer and clears busy (nominal, no regression)", async () => {
|
||||
const gw = new FakeTicketGateway();
|
||||
const { result } = await openAndSend(gw);
|
||||
|
||||
act(() => {
|
||||
gw.captured!({ kind: "textDelta", text: "Titre " });
|
||||
});
|
||||
act(() => {
|
||||
gw.captured!({ kind: "final", content: "Titre mis à jour." });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.busy).toBe(false));
|
||||
const last = result.current.turns.at(-1)!;
|
||||
expect(last.role).toBe("assistant");
|
||||
expect(last.text).toBe("Titre mis à jour.");
|
||||
expect(last.pending).toBeFalsy();
|
||||
});
|
||||
|
||||
it("busy stays true after agent_send resolves, until a terminal chunk arrives", async () => {
|
||||
const gw = new FakeTicketGateway();
|
||||
const { result } = await openAndSend(gw);
|
||||
expect(result.current.busy).toBe(true);
|
||||
|
||||
// `agent_send` returns (stream started) but NO terminal chunk yet: busy must
|
||||
// NOT drop — the input stays disabled while the turn is still in flight.
|
||||
await act(async () => {
|
||||
gw.sendResolve!();
|
||||
});
|
||||
expect(result.current.busy).toBe(true);
|
||||
expect(result.current.turns.at(-1)).toMatchObject({ pending: true });
|
||||
|
||||
// Only the real terminal (here `final`) re-enables the input.
|
||||
act(() => {
|
||||
gw.captured!({ kind: "final", content: "fini" });
|
||||
});
|
||||
await waitFor(() => expect(result.current.busy).toBe(false));
|
||||
expect(result.current.turns.at(-1)).toMatchObject({
|
||||
text: "fini",
|
||||
pending: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -135,19 +135,40 @@ export function useTicketAssistant(
|
||||
} else if (chunk.kind === "toolActivity") {
|
||||
// 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.
|
||||
next[i] = {
|
||||
role: "assistant",
|
||||
text: `⚠️ ${chunk.message}`,
|
||||
pending: false,
|
||||
};
|
||||
} else {
|
||||
// `final`: freeze the turn with the aggregated content.
|
||||
next[i] = { role: "assistant", text: chunk.content, pending: false };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (chunk.kind === "final") setBusy(false);
|
||||
// `busy` tracks the REAL end-of-turn: a `final` or an `error` terminal —
|
||||
// never merely `agent_send` returning (the stream is still flowing then,
|
||||
// ticket #60). The input stays disabled until one of them arrives.
|
||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
||||
};
|
||||
try {
|
||||
await ticket.sendTicketChat(sessionId, text, applyChunk);
|
||||
} catch (e) {
|
||||
// Transport failure: no terminal chunk will arrive, so close the pending
|
||||
// turn and drop `busy` here to guarantee the assistant never hangs.
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setTurns((prev) => {
|
||||
const next = [...prev];
|
||||
const i = next.length - 1;
|
||||
if (i >= 0 && next[i].role === "assistant" && next[i].pending) {
|
||||
next[i] = { ...next[i], pending: false };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user