fix(chat): fallback vers un nouveau lancement quand la session structurée reattach est morte (#147)

reattachAgentChat pouvait échouer avec NOT_FOUND (session structurée fermée
ou jamais vivante) et laisser fuiter l'erreur brute jusqu'à l'utilisateur
("not found: structured session ... not found") au lieu de relancer une
session fraîche. Le fallback ne s'applique qu'au code NOT_FOUND ; toute
autre erreur de reattach continue de remonter telle quelle.

QA vert 2026-08-05 : npx vitest run CustomAgentChatView.test.tsx,
npx vitest run agents.test.tsx CustomAgentChatView.test.tsx, npm run typecheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 12:11:38 +02:00
parent c0764d6a0c
commit b37542107a
2 changed files with 108 additions and 5 deletions

View File

@ -94,4 +94,100 @@ describe("CustomAgentChatView", () => {
expect.any(Function),
);
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
.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="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: "conversation-1",
nodeId: "node-1",
}),
);
await waitFor(() =>
expect(onSessionId).toHaveBeenCalledWith("chat-session-2"),
);
expect(screen.queryByText(/structured session gone/)).toBeNull();
});
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn().mockRejectedValueOnce({
code: "PROCESS",
message: "structured session failed to attach",
}),
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="existing-session"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect((await screen.findByRole("alert")).textContent).toContain(
"structured session failed to attach",
);
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
});

View File

@ -154,11 +154,18 @@ export function CustomAgentChatView({
setError(null);
try {
if (sessionId) {
const reattached = await agent.reattachAgentChat!(sessionId, receive);
if (cancelled) return;
setCurrentSession(reattached.sessionId);
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
return;
try {
const reattached = await agent.reattachAgentChat!(sessionId, receive);
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;
}
}
const launched = await agent.launchAgentChat!(projectId, agentId, {
cwd,