merge(frontend): fix #366 — restaure l'historique durable au retour sur une conversation existante sans session live (QA verte)

This commit is contained in:
2026-09-17 08:51:34 +02:00
6 changed files with 338 additions and 229 deletions

View File

@ -288,30 +288,21 @@ describe("CustomAgentChatView", () => {
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40"); expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
}); });
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { it("restores a durable conversation without starting or sending to the provider (#366)", async () => {
const page = deferred<{
turns: Array<{ id: string; atMs: number; role: "prompt" | "response"; source: { kind: "human" } | { kind: "agent"; agentId: string }; text: string; textLen: number }>;
hasMore: boolean;
}>();
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ launchAgentChat: vi.fn(),
sessionId: "fresh-session", reattachAgentChat: vi.fn(),
transcriptUnavailableReason: "missingTranscript" as const,
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
transcriptUnavailableReason: "missingTranscript" as const,
})),
sendAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}),
}; };
const conversation = { readPage: vi.fn(() => page.promise) };
const onConversationId = vi.fn();
render( render(
<DIProvider <DIProvider gateways={{ agent, conversation, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView <CustomAgentChatView
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
@ -320,97 +311,81 @@ describe("CustomAgentChatView", () => {
cwd="/repo" cwd="/repo"
nodeId="node-1" nodeId="node-1"
sessionId={null} sessionId={null}
conversationId="orphan-conversation" conversationId="saved-conversation"
onSessionId={vi.fn()} onSessionId={vi.fn()}
onConversationId={onConversationId} onConversationId={vi.fn()}
/> />
</DIProvider>, </DIProvider>,
); );
await waitFor(() => expect(await screen.findByText("Chargement de la conversation…")).toBeTruthy();
expect(agent.launchAgentChat).toHaveBeenCalledWith( expect(screen.queryByText("Nouvelle conversation")).toBeNull();
"project-1", page.resolve({
"agent-1", turns: [
expect.objectContaining({ conversationId: "orphan-conversation" }), { id: "p1", atMs: 10, role: "prompt", source: { kind: "human" }, text: "Question sauvegardée", textLen: 20 },
), { id: "r1", atMs: 20, role: "response", source: { kind: "agent", agentId: "agent-1" }, text: "Réponse sauvegardée", textLen: 19 },
); ],
expect( hasMore: false,
await screen.findByText("La conversation liée n’a pas pu être restaurée."), });
).toBeTruthy(); expect(await screen.findByText("Question sauvegardée")).toBeTruthy();
expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy(); expect(screen.getByText("Réponse sauvegardée")).toBeTruthy();
expect( expect(agent.launchAgentChat).not.toHaveBeenCalled();
screen.queryByText("Conversation liée — récupération du transcript en cours."), expect(agent.reattachAgentChat).not.toHaveBeenCalled();
).toBeNull(); expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(screen.queryByRole("progressbar")).toBeNull();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" }));
expect(onConversationId).toHaveBeenCalledWith(null);
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
}); });
it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => { it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })), launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
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="valid-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
</DIProvider>,
);
expect(
await screen.findByText("Envoyez un message pour démarrer la conversation structurée."),
).toBeTruthy();
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
});
it("treats emptyConversation as a normal fresh conversation", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
transcriptUnavailableReason: "emptyConversation" as const,
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
transcriptUnavailableReason: "emptyConversation" as const,
})),
sendAgentChat: vi.fn(async () => {}),
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.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
});
it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => {
const agent = {
launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }),
reattachAgentChat: vi.fn(), reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}),
}; };
const conversation = {
readPage: vi.fn()
.mockRejectedValueOnce(new Error("disk temporarily unavailable"))
.mockResolvedValueOnce({
turns: [{ id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Message restauré", textLen: 16 }],
hasMore: false,
}),
};
const onConversationId = vi.fn();
render( render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}> <DIProvider gateways={{ agent, conversation, 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="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} /> <CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="saved-conversation" onSessionId={vi.fn()} onConversationId={onConversationId} />
</DIProvider>, </DIProvider>,
); );
expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy();
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1); expect(await screen.findByText(/Impossible de charger cette conversation sauvegardée/)).toBeTruthy();
expect(onConversationId).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
expect(await screen.findByText("Message restauré")).toBeTruthy();
expect(conversation.readPage).toHaveBeenCalledTimes(2);
expect(onConversationId).not.toHaveBeenCalled();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("keeps a confirmed missing durable conversation selected until an explicit action", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const conversation = { readPage: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }) };
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, conversation, 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="missing-conversation" onSessionId={vi.fn()} onConversationId={onConversationId} />
</DIProvider>,
);
expect(await screen.findByText(/Cette conversation sauvegardée est introuvable/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Nouvelle conversation" })).toBeTruthy();
expect(onConversationId).not.toHaveBeenCalled();
expect(agent.launchAgentChat).not.toHaveBeenCalled();
}); });
it("projects self-restart lifecycle separators in the custom CLI only once", async () => { it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
@ -430,6 +405,7 @@ describe("CustomAgentChatView", () => {
<DIProvider gateways={{ agent, system } as unknown as Gateways}> <DIProvider gateways={{ agent, system } as unknown as Gateways}>
<AnnouncementsProvider projectId="project-1"> <AnnouncementsProvider projectId="project-1">
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -660,6 +636,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -708,6 +685,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -1510,7 +1488,17 @@ describe("CustomAgentChatView", () => {
{ kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" }, { kind: "final" as const, content: "Initial reply" },
]; ];
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>(); const conversation = {
readPage: vi.fn(async () => ({
turns: [
{ id: "p1", atMs: 1, role: "prompt" as const, source: { kind: "human" as const }, text: "Initial prompt", textLen: 14 },
{ id: "r1", atMs: 2, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Initial reply", textLen: 13 },
{ id: "p2", atMs: 3, role: "prompt" as const, source: { kind: "human" as const }, text: "Question during graph view", textLen: 26 },
{ id: "r2", atMs: 4, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Answer restored from replay", textLen: 27 },
],
hasMore: false,
})),
};
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),
reattachAgentChat: vi reattachAgentChat: vi
@ -1518,8 +1506,7 @@ describe("CustomAgentChatView", () => {
.mockImplementationOnce(async (sessionId: string) => ({ .mockImplementationOnce(async (sessionId: string) => ({
sessionId, sessionId,
scrollback, scrollback,
})) })),
.mockImplementationOnce(() => secondReattach.promise),
detachAgentChat: vi.fn(async () => {}), detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
@ -1542,6 +1529,7 @@ describe("CustomAgentChatView", () => {
<DIProvider <DIProvider
gateways={{ gateways={{
agent, agent,
conversation,
system: { pickFile: vi.fn(async () => null) }, system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways} } as unknown as Gateways}
> >
@ -1553,17 +1541,11 @@ describe("CustomAgentChatView", () => {
first.unmount(); first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
{ kind: "userPrompt" as const, text: "Question during graph view" },
{ kind: "final" as const, content: "Answer restored from replay" },
];
render( render(
<DIProvider <DIProvider
gateways={{ gateways={{
agent, agent,
conversation,
system: { pickFile: vi.fn(async () => null) }, system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways} } as unknown as Gateways}
> >
@ -1575,13 +1557,10 @@ describe("CustomAgentChatView", () => {
expect(agent.launchAgentChat).not.toHaveBeenCalled(); expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true); expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
});
expect(await screen.findByText("Answer restored from replay")).toBeTruthy(); expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
expect(screen.getByText("Question during graph view")).toBeTruthy(); expect(screen.getByText("Question during graph view")).toBeTruthy();
expect(agent.launchAgentChat).not.toHaveBeenCalled(); expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
}); });
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
@ -2172,7 +2151,53 @@ describe("CustomAgentChatView", () => {
expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy();
}); });
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => { it("hides remembered announcements while no live session is attached (#366)", async () => {
const system = new MockSystemGateway();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
<AnnouncementsProvider projectId="project-1">
<CustomAgentChatView
projectId="project-1"
agentId="main"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId={null}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</AnnouncementsProvider>
</DIProvider>,
);
await act(async () => {
await Promise.resolve();
system.emit({
type: "agentAnnouncement",
projectId: "project-1",
requester: "main",
target: "architect",
ticketId: "delegated-1",
text: "ancienne commande shell",
atMs: 1,
});
});
expect(screen.queryByTestId("announcements-preview")).toBeNull();
expect(screen.queryByText("ancienne commande shell")).toBeNull();
});
it("falls back to durable history when a persisted live session is gone", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2", sessionId: "chat-session-2",
@ -2181,21 +2206,22 @@ describe("CustomAgentChatView", () => {
reattachAgentChat: vi reattachAgentChat: vi
.fn() .fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }) .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }) .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }),
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})), sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}),
}; };
const onSessionId = vi.fn(); const onSessionId = vi.fn();
const conversation = { readPage: vi.fn(async () => ({
turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Durable fallback", textLen: 16 }],
hasMore: false,
})) };
render( render(
<DIProvider <DIProvider
gateways={{ gateways={{
agent, agent,
conversation,
system: { pickFile: vi.fn(async () => null) }, system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways} } as unknown as Gateways}
> >
@ -2214,18 +2240,9 @@ describe("CustomAgentChatView", () => {
</DIProvider>, </DIProvider>,
); );
await waitFor(() => expect(await screen.findByText("Durable fallback")).toBeTruthy();
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", { expect(agent.launchAgentChat).not.toHaveBeenCalled();
cwd: "/repo", expect(onSessionId).toHaveBeenCalledWith(null);
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(); expect(screen.queryByText(/structured session gone/)).toBeNull();
}); });
@ -5657,7 +5674,7 @@ describe("CustomAgentChatView", () => {
expect(composer.className).toContain("shrink-0"); expect(composer.className).toContain("shrink-0");
}); });
it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => { it("falls back to durable history when Tauri reports a missing session as text", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2", sessionId: "chat-session-2",
@ -5670,21 +5687,22 @@ describe("CustomAgentChatView", () => {
) )
.mockRejectedValueOnce( .mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6", "not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
) ),
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})), sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}),
}; };
const onSessionId = vi.fn(); const onSessionId = vi.fn();
const conversation = { readPage: vi.fn(async () => ({
turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Historique durable", textLen: 18 }],
hasMore: false,
})) };
render( render(
<DIProvider <DIProvider
gateways={{ gateways={{
agent, agent,
conversation,
system: { pickFile: vi.fn(async () => null) }, system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways} } as unknown as Gateways}
> >
@ -5703,7 +5721,7 @@ describe("CustomAgentChatView", () => {
</DIProvider>, </DIProvider>,
); );
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); expect(await screen.findByText("Historique durable")).toBeTruthy();
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
1, 1,
"69b57638-0ac7-4a79-8252-e0436a3265f6", "69b57638-0ac7-4a79-8252-e0436a3265f6",
@ -5715,13 +5733,8 @@ describe("CustomAgentChatView", () => {
expect.any(Function), expect.any(Function),
{ projectId: "project-1", agentId: "agent-1" }, { projectId: "project-1", agentId: "agent-1" },
); );
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
3,
"chat-session-2",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2"); expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(screen.queryByText(/not found: structured session/)).toBeNull(); expect(screen.queryByText(/not found: structured session/)).toBeNull();
}); });
@ -5751,6 +5764,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -6016,6 +6030,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -6057,6 +6072,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -6114,6 +6130,7 @@ describe("CustomAgentChatView", () => {
} as unknown as Gateways} } as unknown as Gateways}
> >
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId="project-1" projectId="project-1"
agentId="agent-1" agentId="agent-1"
agentName="Worker" agentName="Worker"
@ -6420,7 +6437,7 @@ describe("CustomAgentChatView", () => {
render( render(
<DIProvider gateways={{ <DIProvider gateways={{
agent, agent,
conversation: { readPage: vi.fn() }, conversation: { readPage: vi.fn(async () => ({ turns: history, hasMore: false })) },
system: { pickFile: vi.fn(async () => null) }, system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}> } as unknown as Gateways}>
<CustomAgentChatView <CustomAgentChatView
@ -6452,9 +6469,16 @@ describe("CustomAgentChatView", () => {
expect(agent.sendAgentChat).not.toHaveBeenCalled(); expect(agent.sendAgentChat).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } }); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy(); expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
expect(await screen.findByText("New final")).toBeTruthy(); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"resumed-session",
"Continue",
expect.any(Function),
));
}); });
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => { it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {

View File

@ -1069,9 +1069,8 @@ export function CustomAgentChatView({
() => new Set(), () => new Set(),
); );
const [currentSession, setCurrentSession] = useState(sessionId); const [currentSession, setCurrentSession] = useState(sessionId);
// A newly opened custom CLI intentionally starts blank. A conversation id // `freshOnOpen` is reserved for an explicit reset intent. Ordinary React
// persisted on the layout is only reused when it still has a live session; // remounts keep the durable conversation selected by the hosting cell.
// durable threads are reopened explicitly from the Conversations menu.
const [activeConversationId, setActiveConversationId] = useState<string | null>( const [activeConversationId, setActiveConversationId] = useState<string | null>(
sessionId || !freshOnOpen ? conversationId : null, sessionId || !freshOnOpen ? conversationId : null,
); );
@ -1113,6 +1112,10 @@ export function CustomAgentChatView({
const [opening, setOpening] = useState( const [opening, setOpening] = useState(
Boolean(sessionId || (!freshOnOpen && conversationId)), Boolean(sessionId || (!freshOnOpen && conversationId)),
); );
const [liveSessionAttached, setLiveSessionAttached] = useState(false);
const [savedConversationLoadState, setSavedConversationLoadState] = useState<
"idle" | "loading" | "loaded" | "error" | "notFound"
>(!sessionId && !freshOnOpen && conversationId ? "loading" : "idle");
const [openRetryNonce, setOpenRetryNonce] = useState(0); const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState< const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
ConversationTranscriptUnavailableReason | "conversationNotFound" | null ConversationTranscriptUnavailableReason | "conversationNotFound" | null
@ -1904,6 +1907,7 @@ export function CustomAgentChatView({
}); });
if (!mountedRef.current) return reattached.sessionId; if (!mountedRef.current) return reattached.sessionId;
setCurrentSession(reattached.sessionId); setCurrentSession(reattached.sessionId);
setLiveSessionAttached(true);
if (reattached.sessionId !== sid) { if (reattached.sessionId !== sid) {
publishSessionId(reattached.sessionId); publishSessionId(reattached.sessionId);
} }
@ -2124,10 +2128,18 @@ export function CustomAgentChatView({
return; return;
} }
setExternalSessionId(sessionId); setExternalSessionId(sessionId);
setLiveSessionAttached(false);
}, [sessionId]); }, [sessionId]);
useEffect(() => { useEffect(() => {
if (!supported) return; if (!supported) return;
if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) {
setOpening(false);
setError(null);
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
return;
}
let cancelled = false; let cancelled = false;
async function openOrAttach() { async function openOrAttach() {
@ -2152,6 +2164,8 @@ export function CustomAgentChatView({
return; return;
} }
if (externalSessionId) { if (externalSessionId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
const cached = chatSessionViewCache.get(externalSessionId); const cached = chatSessionViewCache.get(externalSessionId);
if (cached) { if (cached) {
scrollbackChunkCountRef.current = cached.chunkCount; scrollbackChunkCountRef.current = cached.chunkCount;
@ -2194,6 +2208,8 @@ export function CustomAgentChatView({
} }
if (!cancelled) { if (!cancelled) {
setCurrentSession(null); setCurrentSession(null);
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null); publishSessionId(null);
} }
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
@ -2202,6 +2218,7 @@ export function CustomAgentChatView({
sessionId: externalSessionId, sessionId: externalSessionId,
error: instrumentationError(e), error: instrumentationError(e),
}); });
return;
} }
} else { } else {
const cacheKey = conversationCacheKey( const cacheKey = conversationCacheKey(
@ -2217,32 +2234,45 @@ export function CustomAgentChatView({
progressIndex: cached.progressIndex ?? new Map(), progressIndex: cached.progressIndex ?? new Map(),
progressScope: cached.progressScope ?? 0, progressScope: cached.progressScope ?? 0,
}); });
if (cached.sessionId) {
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
applyScrollback: true,
});
if (cancelled) return;
publishSessionId(reattachedSessionId);
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: reattachedSessionId,
});
return;
}
return;
}
} }
const savedConversationId = activeConversationIdRef.current;
if (!savedConversationId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
if (freshOnOpen) {
await recoverStructuredSession({ await recoverStructuredSession({
applyScrollback: Boolean(activeConversationIdRef.current), applyScrollback: false,
retryAttachNotFound: true, retryAttachNotFound: true,
conversationId: null,
});
}
return;
}
setSavedConversationLoadState("loading");
setLiveSessionAttached(false);
try {
const page = await conversation.readPage(projectId, savedConversationId, {
direction: "backward",
limit: 50,
}); });
if (cancelled) return; if (cancelled) return;
console.debug("[ticket149] openOrAttach:recover:success", { applyChatViewState({
timestamp: new Date().toISOString(), ...emptyChatViewState(),
execution, turns: durableHistoryTurns(page.turns),
receivedSessionId: externalSessionId,
}); });
setResumedConversation(true);
setConversationNotice(null);
setHistoryHasMore(page.hasMore);
setHistoryNextAnchor(page.nextAnchor);
setHistoryError(null);
setSavedConversationLoadState("loaded");
} catch (e) {
if (cancelled) return;
setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error");
}
return;
}
} catch (e) { } catch (e) {
console.error("[ticket149] openOrAttach:error", { console.error("[ticket149] openOrAttach:error", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -2273,6 +2303,10 @@ export function CustomAgentChatView({
recoverStructuredSession, recoverStructuredSession,
publishSessionId, publishSessionId,
applyChatViewState, applyChatViewState,
conversation,
projectId,
agentId,
freshOnOpen,
openRetryNonce, openRetryNonce,
]); ]);
@ -2280,8 +2314,10 @@ export function CustomAgentChatView({
() => () =>
supported && supported &&
(Boolean(draft.trim()) || attachments.length > 0) && (Boolean(draft.trim()) || attachments.length > 0) &&
!opening, !opening &&
[supported, draft, attachments.length, opening], savedConversationLoadState !== "error" &&
savedConversationLoadState !== "notFound",
[supported, draft, attachments.length, opening, savedConversationLoadState],
); );
const unavailableLinkedTranscript = const unavailableLinkedTranscript =
displayTurns.length === 0 && displayTurns.length === 0 &&
@ -2289,16 +2325,17 @@ export function CustomAgentChatView({
transcriptUnavailableReason === "conversationNotFound") transcriptUnavailableReason === "conversationNotFound")
? transcriptUnavailableReason ? transcriptUnavailableReason
: null; : null;
const savedConversationLoadFailure =
function unlinkUnavailableConversation() { savedConversationLoadState === "error" || savedConversationLoadState === "notFound"
onConversationIdRef.current(null); ? savedConversationLoadState
setTranscriptUnavailableReason("emptyConversation"); : null;
setError(null);
}
function retryUnavailableTranscript() { function retryUnavailableTranscript() {
const sid = sessionRef.current; const sid = sessionRef.current;
setTranscriptUnavailableReason(null); setTranscriptUnavailableReason(null);
setSavedConversationLoadState(
!sid && activeConversationIdRef.current ? "loading" : "idle",
);
setError(null); setError(null);
if (sid) { if (sid) {
// Force the next user-triggered pass through the attach path. Keeping the // Force the next user-triggered pass through the attach path. Keeping the
@ -2343,9 +2380,11 @@ export function CustomAgentChatView({
if (sid) await agent.closeAgentChat?.(sid); if (sid) await agent.closeAgentChat?.(sid);
setCurrentSession(null); setCurrentSession(null);
setExternalSessionId(null); setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null); publishSessionId(null);
publishConversationId(nextConversationId); publishConversationId(nextConversationId);
applyChatViewState(emptyChatViewState()); applyChatViewState(emptyChatViewState());
setSavedConversationLoadState("loading");
setResumedConversation(false); setResumedConversation(false);
setConversationNotice(null); setConversationNotice(null);
setHistoryHasMore(false); setHistoryHasMore(false);
@ -2492,6 +2531,7 @@ export function CustomAgentChatView({
(await recoverStructuredSession({ (await recoverStructuredSession({
applyScrollback: false, applyScrollback: false,
retryAttachNotFound: true, retryAttachNotFound: true,
...(invocation.name === "/clean" ? { conversationId: null } : {}),
})); }));
const result = await agent.executeSlashCommand(invocation.name, { const result = await agent.executeSlashCommand(invocation.name, {
sessionId: sid, sessionId: sid,
@ -2513,6 +2553,7 @@ export function CustomAgentChatView({
setExternalSessionId(result.effect.sessionId); setExternalSessionId(result.effect.sessionId);
publishSessionId(result.effect.sessionId); publishSessionId(result.effect.sessionId);
publishConversationId(result.effect.conversationId ?? null); publishConversationId(result.effect.conversationId ?? null);
setSavedConversationLoadState("idle");
setResumedConversation(false); setResumedConversation(false);
setHistoryHasMore(false); setHistoryHasMore(false);
setHistoryNextAnchor(undefined); setHistoryNextAnchor(undefined);
@ -3247,13 +3288,15 @@ export function CustomAgentChatView({
className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
onScroll={threadScroll.onScroll} onScroll={threadScroll.onScroll}
> >
{liveSessionAttached && delegationActive && (
<div className="shrink-0"> <div className="shrink-0">
<AnnouncementsPreview <AnnouncementsPreview
requester={agentId} requester={agentId}
fallbackActive={delegationActive} fallbackActive
fallbackText={delegationFallbackText} fallbackText={delegationFallbackText}
/> />
</div> </div>
)}
{conversationNotice && ( {conversationNotice && (
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success"> <p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
{conversationNotice} {conversationNotice}
@ -3291,18 +3334,26 @@ export function CustomAgentChatView({
<div className="flex items-center gap-2 text-sm text-muted"> <div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} /> <Spinner size={14} />
<span> <span>
{externalSessionId ? t("custom.resume") : t("custom.opening")} {savedConversationLoadState === "loading"
? tr("Chargement de la conversation…", "Loading conversation…")
: externalSessionId
? t("custom.resume")
: t("custom.opening")}
</span> </span>
</div> </div>
) : unavailableLinkedTranscript ? ( ) : savedConversationLoadFailure || unavailableLinkedTranscript ? (
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center"> <div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
<p className="text-sm font-medium text-content"> <p className="text-sm font-medium text-content">
{tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")} {savedConversationLoadFailure === "notFound" ||
</p> unavailableLinkedTranscript === "conversationNotFound"
<p className="text-xs text-muted"> ? tr(
{unavailableLinkedTranscript === "conversationNotFound" "Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.",
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.") "This saved conversation could not be found. You can retry or open a new conversation.",
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")} )
: tr(
"Impossible de charger cette conversation sauvegardée. Vos messages n'ont pas été supprimés. Réessayez ou ouvrez explicitement une nouvelle conversation.",
"Unable to load this saved conversation. Your messages have not been deleted. Retry or explicitly open a new conversation.",
)}
</p> </p>
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
<Button <Button
@ -3313,11 +3364,18 @@ export function CustomAgentChatView({
> >
{t("common.retry")} {t("common.retry")}
</Button> </Button>
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}> <Button size="sm" variant="secondary" onClick={() => void startNewConversation()}>
{tr("Délier la conversation", "Unlink conversation")} {tr("Nouvelle conversation", "New conversation")}
</Button> </Button>
</div> </div>
</div> </div>
) : savedConversationLoadState === "loaded" && displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
{tr(
"Cette conversation sauvegardée ne contient aucun message affichable.",
"This saved conversation contains no displayable messages.",
)}
</div>
) : displayTurns.length === 0 ? ( ) : displayTurns.length === 0 ? (
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted"> <div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
<strong className="block text-sm text-content"> <strong className="block text-sm text-content">
@ -3565,7 +3623,7 @@ export function CustomAgentChatView({
)} )}
rows={2} rows={2}
value={draft} value={draft}
disabled={!supported || opening} disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
placeholder={t("custom.message")} placeholder={t("custom.message")}
style={{ height: clampComposerHeight(composerHeightPx) }} style={{ height: clampComposerHeight(composerHeightPx) }}
onChange={(e) => editDraft(e.target.value)} onChange={(e) => editDraft(e.target.value)}
@ -3623,7 +3681,7 @@ export function CustomAgentChatView({
size="sm" size="sm"
variant="ghost" variant="ghost"
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
disabled={!supported || opening} disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
onClick={() => void pickAttachment()} onClick={() => void pickAttachment()}
> >
{t("custom.attach")} {t("custom.attach")}

View File

@ -47,6 +47,7 @@ import type { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { import {
MockAgentGateway, MockAgentGateway,
MockConversationGateway,
MockLayoutGateway, MockLayoutGateway,
MockProfileGateway, MockProfileGateway,
MockSystemGateway, MockSystemGateway,
@ -97,6 +98,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const workState = new MockWorkStateGateway(); const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([profile]); await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", { const created = await agent.createAgent("p1", {
name: "Worker", name: "Worker",
@ -116,6 +118,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
terminal, terminal,
system, system,
workState, workState,
conversation,
} as unknown as Gateways; } as unknown as Gateways;
} }
@ -175,6 +178,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), { fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello there" }, target: { value: "hello there" },
}); });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByText("Envoyer")); fireEvent.click(screen.getByText("Envoyer"));
await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy()); await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy());
@ -204,13 +210,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
}); });
it("restores a project custom CLI cell after switching away and back without rewriting durable ids", async () => { it("restores durable messages after project A → B → A without starting a provider", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const workState = new MockWorkStateGateway(); const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]); await profileGateway.configureProfiles([structuredProfile]);
const agentA = await agent.createAgent("p1", { const agentA = await agent.createAgent("p1", {
@ -229,6 +236,15 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
target: leafA.id, target: leafA.id,
preferredView: "chat", preferredView: "chat",
}); });
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafA.id,
conversationId: "saved-a",
});
conversation._setTurns("p1", "saved-a", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "Question projet A", textLen: 17 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: agentA.id }, text: "Réponse projet A", textLen: 16 },
]);
const agentB = await agent.createAgent("p2", { const agentB = await agent.createAgent("p2", {
name: "Worker B", name: "Worker B",
@ -249,17 +265,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
terminal, terminal,
system, system,
workState, workState,
conversation,
} as unknown as Gateways; } as unknown as Gateways;
const launchChat = vi.spyOn(agent, "launchAgentChat");
const view = renderGrid(gateways, "p1"); const view = renderGrid(gateways, "p1");
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
await waitFor(async () => { expect(await screen.findByText("Question projet A")).toBeTruthy();
const updated = await layout.loadLayout("p1"); expect(screen.getByText("Réponse projet A")).toBeTruthy();
const leaf = leaves(updated)[0];
expect(leaf.preferredView).toBe("chat");
expect(leaf.session).toMatch(/^mock-agent-chat-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/);
});
const persistedA = leaves(await layout.loadLayout("p1"))[0]; const persistedA = leaves(await layout.loadLayout("p1"))[0];
view.rerender( view.rerender(
@ -280,10 +293,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" })) expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull(); .toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull(); expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
expect(await screen.findByText("Question projet A")).toBeTruthy();
expect(screen.getByText("Réponse projet A")).toBeTruthy();
const restoredA = leaves(await layout.loadLayout("p1"))[0]; const restoredA = leaves(await layout.loadLayout("p1"))[0];
expect(restoredA.preferredView).toBe("chat"); expect(restoredA.preferredView).toBe("chat");
expect(restoredA.session).toBe(persistedA.session); expect(restoredA.session).toBe(persistedA.session);
expect(restoredA.conversationId).toBe(persistedA.conversationId); expect(restoredA.conversationId).toBe(persistedA.conversationId);
expect(launchChat).not.toHaveBeenCalled();
}); });
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => { it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
@ -343,7 +359,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(sendAgentChat).not.toHaveBeenCalled(); expect(sendAgentChat).not.toHaveBeenCalled();
}); });
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => { it("does not render requester announcements without a live attached session (#366)", async () => {
const gateways = await seeded(structuredProfile); const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway; const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway; const workState = gateways.workState as MockWorkStateGateway;
@ -386,13 +402,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
const view = renderGrid(gateways); const view = renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy(); expect(screen.queryByText("Worker attend Architect")).toBeNull();
view.unmount(); view.unmount();
renderGrid(gateways); renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Worker attend Architect")).toBeTruthy(); expect(screen.queryByText("Worker attend Architect")).toBeNull();
}); });
it("persists custom CLI composer height per layout cell", async () => { it("persists custom CLI composer height per layout cell", async () => {
@ -438,7 +454,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
} }
}); });
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => { it("does not attach an unrelated structured background session implicitly (#169/#366)", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
@ -476,17 +492,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
); );
await waitFor(() => expect(launchChat).toHaveBeenCalled()); expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); expect(launchChat).not.toHaveBeenCalled();
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
}); });
it("starts a fresh custom CLI conversation when returning without a live session (#363)", async () => { it("restores a saved custom CLI conversation when returning without a live session (#366)", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]); await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", { const created = await agent.createAgent("p1", {
name: "Worker", name: "Worker",
@ -505,6 +522,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
conversationId: "conversation-known", conversationId: "conversation-known",
}); });
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
conversation._setTurns("p1", "conversation-known", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "known prompt", textLen: 12 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: created.id }, text: "known response", textLen: 14 },
]);
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({ const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-fresh", sessionId: "chat-fresh",
cellKind: "chat" as const, cellKind: "chat" as const,
@ -521,27 +542,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
profile: profileGateway, profile: profileGateway,
terminal, terminal,
system, system,
conversation,
} as unknown as Gateways); } as unknown as Gateways);
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy(); expect(await screen.findByText("known prompt")).toBeTruthy();
expect(screen.queryByText("known prompt")).toBeNull(); expect(screen.getByText("known response")).toBeTruthy();
expect(launchChat).toHaveBeenCalledWith( expect(screen.queryByText("Nouvelle conversation")).toBeNull();
"p1", expect(launchChat).not.toHaveBeenCalled();
created.id, expect(reattachChat).not.toHaveBeenCalled();
expect.objectContaining({
nodeId: leafId,
conversationId: undefined,
}),
);
expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function));
await waitFor(async () => {
const updated = await layout.loadLayout("p1"); const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0]; expect(leaves(updated)[0].conversationId).toBe("conversation-known");
expect(leaf.conversationId).toBe("conversation-fresh"); expect(leaves(updated)[0].session).toBeNull();
expect(leaf.session).toBe("chat-fresh");
});
expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée."))
.toBeTruthy();
}); });
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => { it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
@ -646,6 +657,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
); );
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start live chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(async () => { await waitFor(async () => {
const updated = await layout.loadLayout("p1"); const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/); expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
@ -701,6 +716,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
); );
expect(stopLive).not.toHaveBeenCalled(); expect(stopLive).not.toHaveBeenCalled();
expect(closeTerminal).not.toHaveBeenCalled(); expect(closeTerminal).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start structured chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(launchChat).toHaveBeenCalled()); await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
await waitFor(async () => { await waitFor(async () => {

View File

@ -1695,7 +1695,6 @@ function LeafView({
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView <CustomAgentChatView
key={`${id}-${agentId}-custom`} key={`${id}-${agentId}-custom`}
freshOnOpen
projectId={projectId} projectId={projectId}
agentId={agentId} agentId={agentId}
agentName={customCliAgent.name} agentName={customCliAgent.name}

View File

@ -68,6 +68,12 @@ function renderCell(
); );
} }
async function startCustomConversation(message = "Start mobile chat") {
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: message } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
}
describe("WebAgentCell", () => { describe("WebAgentCell", () => {
it("mounts and renders the cell without throwing (headless-safe)", async () => { it("mounts and renders the cell without throwing (headless-safe)", async () => {
const { agent, profile, agentId } = await seededAgentGateway(); const { agent, profile, agentId } = await seededAgentGateway();
@ -113,6 +119,7 @@ describe("WebAgentCell", () => {
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("terminal-key-bar")).toBeNull(); expect(screen.queryByTestId("terminal-key-bar")).toBeNull();
await startCustomConversation();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" }); expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId"); expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId");
@ -128,6 +135,7 @@ describe("WebAgentCell", () => {
nodeId: leafNodeId, nodeId: leafNodeId,
}); });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await startCustomConversation();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
@ -162,6 +170,7 @@ describe("WebAgentCell", () => {
renderCell(agent, profile, agentId, { fullHeight: true }); renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await startCustomConversation();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1));
@ -186,6 +195,7 @@ describe("WebAgentCell", () => {
renderCell(agent, profile, agentId, { fullHeight: true }); renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await startCustomConversation();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
const sessionId = (await launchChatSpy.mock.results[0].value).sessionId; const sessionId = (await launchChatSpy.mock.results[0].value).sessionId;
@ -197,7 +207,7 @@ describe("WebAgentCell", () => {
expect(launchChatSpy).toHaveBeenCalledTimes(1); expect(launchChatSpy).toHaveBeenCalledTimes(1);
}); });
it("surfaces a generic mobile custom CLI launch failure", async () => { it("surfaces a user-triggered mobile custom CLI launch failure", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({ const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({
code: "PROCESS", code: "PROCESS",
@ -207,14 +217,14 @@ describe("WebAgentCell", () => {
renderCell(agent, profile, agentId, { fullHeight: true }); renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await startCustomConversation();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" }); expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId"); expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId");
expect(reattachChatSpy).not.toHaveBeenCalled(); expect(reattachChatSpy).not.toHaveBeenCalled();
const alertText = (await screen.findByRole("alert")).textContent ?? ""; const alertText = (await screen.findByRole("alert")).textContent ?? "";
expect(alertText).toContain("Le CLI custom n'a pas pu démarrer"); expect(alertText).toContain("invalid node id: web-mobile-agent-1");
expect(alertText).not.toContain("invalid node id");
}); });
it("falls back to native TUI when the mobile agent loses custom CLI support", async () => { it("falls back to native TUI when the mobile agent loses custom CLI support", async () => {

View File

@ -200,7 +200,6 @@ export function WebAgentCell({
)} )}
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? ( {mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
<CustomAgentChatView <CustomAgentChatView
freshOnOpen
projectId={projectId} projectId={projectId}
agentId={agentId} agentId={agentId}
agentName={resolvedAgent.name} agentName={resolvedAgent.name}