fix(chat): relance propre au lieu de re-tenter l'attach sur une session structurée morte (#148)

Sur reattach NOT_FOUND, le composant retentait implicitement l'attach sur le
même sessionId mort au lieu de nettoyer l'état avant de relancer, ce qui
produisait l'erreur "not found: structured session …" côté utilisateur.
recoverStructuredSession() nettoie désormais sessionId/conversationId avant
de relancer et retente une seule fois le reattach sur NOT_FOUND post-launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:17:31 +02:00
parent fab172094a
commit e80f7631ff
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(),