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>
438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { DIProvider } from "@/app/di";
|
|
import type { AgentProfile } from "@/domain";
|
|
import type { Gateways } from "@/ports";
|
|
import { CustomAgentChatView } from "./CustomAgentChatView";
|
|
|
|
const profile: AgentProfile = {
|
|
id: "structured",
|
|
name: "Structured Codex",
|
|
command: "codex",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
|
detect: null,
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "codex",
|
|
};
|
|
|
|
describe("CustomAgentChatView", () => {
|
|
it("cancels only the current turn and keeps the structured session alive", async () => {
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(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="chat-session-1"
|
|
conversationId="conversation-1"
|
|
onSessionId={onSessionId}
|
|
onConversationId={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
|
"chat-session-1",
|
|
expect.any(Function),
|
|
),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
|
target: { value: "first turn" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
|
|
await waitFor(() =>
|
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
|
"chat-session-1",
|
|
"first turn",
|
|
expect.any(Function),
|
|
),
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
|
|
|
await waitFor(() =>
|
|
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
|
);
|
|
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
|
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
|
expect(screen.getByText("Tour interrompu.")).toBeTruthy();
|
|
|
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
|
target: { value: "second turn" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
|
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
|
|
expect(agent.sendAgentChat).toHaveBeenLastCalledWith(
|
|
"chat-session-1",
|
|
"second turn",
|
|
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("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(),
|
|
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();
|
|
});
|
|
});
|