merge(chat): intègre fix/ticket148-structured-session-not-found — relance propre sur reattach mort (#148, QA verte)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:17:39 +02:00
2 changed files with 365 additions and 35 deletions

View File

@ -151,6 +151,250 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByText(/structured session gone/)).toBeNull();
});
it("recovers and retries the prompt when send reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi
.fn()
.mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session stale-session",
})
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Recovered reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"stale-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
1,
"stale-session",
"please recover",
expect.any(Function),
);
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
"please recover",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(await screen.findByText("Recovered reply")).toBeTruthy();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("treats NOT_FOUND during cancel as an already-gone session", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn().mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session gone-session",
}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="gone-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"gone-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "long turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
expect(await screen.findByText("Session déjà fermée.")).toBeTruthy();
expect(onSessionId).toHaveBeenCalledWith(null);
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("does not swallow post-launch reattach errors", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "new-session",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn().mockRejectedValueOnce({
code: "PROCESS",
message: "post-launch attach failed",
}),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect((await screen.findByRole("alert")).textContent).toContain(
"post-launch attach failed",
);
});
it("recovers when post-launch reattach reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi
.fn()
.mockResolvedValueOnce({
sessionId: "new-session-1",
assignedConversationId: "conversation-2",
})
.mockResolvedValueOnce({
sessionId: "new-session-2",
assignedConversationId: "conversation-2",
}),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session new-session-1",
})
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2));
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
1,
"new-session-1",
expect.any(Function),
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"new-session-2",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("new-session-2");
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {
const agent = {
launchAgentChat: vi.fn(),

View File

@ -6,7 +6,7 @@
* deliberately does not try to parse PTY bytes.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
import { useGateways } from "@/app/di";
@ -40,6 +40,10 @@ function describe(e: unknown): string {
return String(e);
}
function isNotFound(e: unknown): boolean {
return Boolean(e && typeof e === "object" && (e as GatewayError).code === "NOT_FOUND");
}
function unknownChunkLabel(chunk: unknown): string {
try {
return JSON.stringify(chunk);
@ -122,6 +126,7 @@ export function CustomAgentChatView({
const scrollRef = useRef<HTMLDivElement | null>(null);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const mountedRef = useRef(false);
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const onConversationIdRef = useRef(onConversationId);
@ -136,6 +141,78 @@ export function CustomAgentChatView({
agent.closeAgentChat,
);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const receive = useCallback((chunk: ReplyChunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
}, []);
const reattachStructuredSession = useCallback(
async (sid: string, options: { applyScrollback?: boolean } = {}) => {
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
const reattached = await agent.reattachAgentChat(sid, receive);
if (!mountedRef.current) return reattached.sessionId;
setCurrentSession(reattached.sessionId);
if (options.applyScrollback) {
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
}
return reattached.sessionId;
},
[agent, receive],
);
const recoverStructuredSession = useCallback(
async (options: { applyScrollback?: boolean; retryAttachNotFound?: boolean } = {}) => {
if (!agent.launchAgentChat) throw new Error("Structured launch unavailable");
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
for (;;) {
const launched = await agent.launchAgentChat(projectId, agentId, {
cwd,
rows: 24,
cols: 80,
conversationId: conversationId ?? undefined,
nodeId,
});
if (!mountedRef.current) return launched.sessionId;
setCurrentSession(launched.sessionId);
onSessionIdRef.current(launched.sessionId);
if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId);
}
try {
return await reattachStructuredSession(launched.sessionId, {
applyScrollback: options.applyScrollback,
});
} catch (e) {
if (isNotFound(e) && retryAttachNotFound) {
retryAttachNotFound = false;
if (mountedRef.current) {
setCurrentSession(null);
onSessionIdRef.current(null);
}
continue;
}
throw e;
}
}
},
[
agent,
projectId,
agentId,
cwd,
nodeId,
conversationId,
reattachStructuredSession,
],
);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
@ -144,10 +221,6 @@ export function CustomAgentChatView({
useEffect(() => {
if (!supported) return;
let cancelled = false;
const receive = (chunk: ReplyChunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
};
async function openOrAttach() {
setOpening(true);
@ -155,33 +228,25 @@ export function CustomAgentChatView({
try {
if (sessionId) {
try {
const reattached = await agent.reattachAgentChat!(sessionId, receive);
await reattachStructuredSession(sessionId, { applyScrollback: true });
if (cancelled) return;
setCurrentSession(reattached.sessionId);
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
return;
} catch (e) {
// The backend contract for `reattach_agent_chat` (NOT_FOUND) is that
// the session is gone (closed/never live) and the caller falls back
// to a fresh launch — any other error still surfaces to the user.
if ((e as GatewayError)?.code !== "NOT_FOUND") throw e;
if (!isNotFound(e)) throw e;
if (!cancelled) {
setCurrentSession(null);
onSessionIdRef.current(null);
}
}
}
const launched = await agent.launchAgentChat!(projectId, agentId, {
cwd,
rows: 24,
cols: 80,
conversationId: conversationId ?? undefined,
nodeId,
await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
});
if (cancelled) return;
setCurrentSession(launched.sessionId);
onSessionIdRef.current(launched.sessionId);
if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId);
}
// Attach the view so any in-flight chunks can be replayed after launch.
await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {});
} catch (e) {
if (!cancelled) setError(describe(e));
} finally {
@ -195,23 +260,18 @@ export function CustomAgentChatView({
};
}, [
supported,
agent,
projectId,
agentId,
cwd,
nodeId,
sessionId,
conversationId,
reattachStructuredSession,
recoverStructuredSession,
]);
const canSend = useMemo(
() =>
supported &&
Boolean(currentSession) &&
Boolean(draft.trim()) &&
!busy &&
!opening,
[supported, currentSession, draft, busy, opening],
[supported, draft, busy, opening],
);
async function pickAttachment() {
@ -221,7 +281,7 @@ export function CustomAgentChatView({
async function send() {
const text = draft.trim();
if (!canSend || !currentSession || !agent.sendAgentChat) return;
if (!canSend || !agent.sendAgentChat) return;
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
setDraft("");
setAttachment(null);
@ -229,11 +289,31 @@ export function CustomAgentChatView({
setError(null);
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
try {
await agent.sendAgentChat(currentSession, prompt, (chunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
});
const sid =
currentSession ??
(await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
}));
await agent.sendAgentChat(sid, prompt, receive);
} catch (e) {
if (isNotFound(e)) {
try {
setCurrentSession(null);
onSessionIdRef.current(null);
const recoveredSession = await recoverStructuredSession({
applyScrollback: false,
retryAttachNotFound: true,
});
await agent.sendAgentChat(recoveredSession, prompt, receive);
return;
} catch (recoveryError) {
setBusy(false);
setError(describe(recoveryError));
setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]);
return;
}
}
setBusy(false);
setError(describe(e));
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
@ -250,6 +330,12 @@ export function CustomAgentChatView({
await agent.cancelAgentChat(sid);
setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]);
} catch (e) {
if (isNotFound(e)) {
setCurrentSession(null);
onSessionIdRef.current(null);
setTurns((prev) => [...prev, { role: "tool", label: "Session déjà fermée." }]);
return;
}
setError(describe(e));
}
}