merge(frontend): fix #366 — restaure l'historique durable au retour sur une conversation existante sans session live (QA verte)
This commit is contained in:
@ -288,30 +288,21 @@ describe("CustomAgentChatView", () => {
|
||||
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 = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
sessionId: "fresh-session",
|
||||
transcriptUnavailableReason: "missingTranscript" as const,
|
||||
})),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
transcriptUnavailableReason: "missingTranscript" as const,
|
||||
})),
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const onConversationId = vi.fn();
|
||||
const conversation = { readPage: vi.fn(() => page.promise) };
|
||||
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"
|
||||
@ -320,97 +311,81 @@ describe("CustomAgentChatView", () => {
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId={null}
|
||||
conversationId="orphan-conversation"
|
||||
conversationId="saved-conversation"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={onConversationId}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(agent.launchAgentChat).toHaveBeenCalledWith(
|
||||
"project-1",
|
||||
"agent-1",
|
||||
expect.objectContaining({ conversationId: "orphan-conversation" }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||||
).toBeNull();
|
||||
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();
|
||||
expect(await screen.findByText("Chargement de la conversation…")).toBeTruthy();
|
||||
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
|
||||
page.resolve({
|
||||
turns: [
|
||||
{ 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 },
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
expect(await screen.findByText("Question sauvegardée")).toBeTruthy();
|
||||
expect(screen.getByText("Réponse sauvegardée")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
expect(agent.reattachAgentChat).not.toHaveBeenCalled();
|
||||
expect(agent.sendAgentChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 = {
|
||||
launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })),
|
||||
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" }; }),
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: 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(
|
||||
<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="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
<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="saved-conversation" onSessionId={vi.fn()} onConversationId={onConversationId} />
|
||||
</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: "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 () => {
|
||||
@ -430,6 +405,7 @@ describe("CustomAgentChatView", () => {
|
||||
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||||
<AnnouncementsProvider projectId="project-1">
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -660,6 +636,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -708,6 +685,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -1510,7 +1488,17 @@ describe("CustomAgentChatView", () => {
|
||||
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||
{ 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 = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi
|
||||
@ -1518,8 +1506,7 @@ describe("CustomAgentChatView", () => {
|
||||
.mockImplementationOnce(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback,
|
||||
}))
|
||||
.mockImplementationOnce(() => secondReattach.promise),
|
||||
})),
|
||||
detachAgentChat: vi.fn(async () => {}),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
@ -1542,6 +1529,7 @@ describe("CustomAgentChatView", () => {
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
conversation,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
@ -1553,17 +1541,11 @@ describe("CustomAgentChatView", () => {
|
||||
first.unmount();
|
||||
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(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
conversation,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
@ -1575,13 +1557,10 @@ describe("CustomAgentChatView", () => {
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
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(screen.getByText("Question during graph view")).toBeTruthy();
|
||||
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 () => {
|
||||
@ -2172,7 +2151,53 @@ describe("CustomAgentChatView", () => {
|
||||
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 = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
sessionId: "chat-session-2",
|
||||
@ -2181,21 +2206,22 @@ describe("CustomAgentChatView", () => {
|
||||
reattachAgentChat: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
|
||||
.mockImplementationOnce(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }),
|
||||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
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(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
conversation,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
@ -2214,18 +2240,9 @@ describe("CustomAgentChatView", () => {
|
||||
</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(await screen.findByText("Durable fallback")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
expect(onSessionId).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByText(/structured session gone/)).toBeNull();
|
||||
});
|
||||
|
||||
@ -5657,7 +5674,7 @@ describe("CustomAgentChatView", () => {
|
||||
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 = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
sessionId: "chat-session-2",
|
||||
@ -5670,21 +5687,22 @@ describe("CustomAgentChatView", () => {
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||||
)
|
||||
.mockImplementationOnce(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
),
|
||||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
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(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
conversation,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
@ -5703,7 +5721,7 @@ describe("CustomAgentChatView", () => {
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText("Historique durable")).toBeTruthy();
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||||
@ -5715,13 +5733,8 @@ describe("CustomAgentChatView", () => {
|
||||
expect.any(Function),
|
||||
{ projectId: "project-1", agentId: "agent-1" },
|
||||
);
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"chat-session-2",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(onSessionId).toHaveBeenCalledWith(null);
|
||||
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/not found: structured session/)).toBeNull();
|
||||
});
|
||||
|
||||
@ -5751,6 +5764,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -6016,6 +6030,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -6057,6 +6072,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -6114,6 +6130,7 @@ describe("CustomAgentChatView", () => {
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
@ -6420,7 +6437,7 @@ describe("CustomAgentChatView", () => {
|
||||
render(
|
||||
<DIProvider gateways={{
|
||||
agent,
|
||||
conversation: { readPage: vi.fn() },
|
||||
conversation: { readPage: vi.fn(async () => ({ turns: history, hasMore: false })) },
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}>
|
||||
<CustomAgentChatView
|
||||
@ -6452,9 +6469,16 @@ describe("CustomAgentChatView", () => {
|
||||
expect(agent.sendAgentChat).not.toHaveBeenCalled();
|
||||
|
||||
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" }));
|
||||
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 () => {
|
||||
|
||||
@ -1069,9 +1069,8 @@ export function CustomAgentChatView({
|
||||
() => new Set(),
|
||||
);
|
||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||
// A newly opened custom CLI intentionally starts blank. A conversation id
|
||||
// persisted on the layout is only reused when it still has a live session;
|
||||
// durable threads are reopened explicitly from the Conversations menu.
|
||||
// `freshOnOpen` is reserved for an explicit reset intent. Ordinary React
|
||||
// remounts keep the durable conversation selected by the hosting cell.
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(
|
||||
sessionId || !freshOnOpen ? conversationId : null,
|
||||
);
|
||||
@ -1113,6 +1112,10 @@ export function CustomAgentChatView({
|
||||
const [opening, setOpening] = useState(
|
||||
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 [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
||||
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||
@ -1904,6 +1907,7 @@ export function CustomAgentChatView({
|
||||
});
|
||||
if (!mountedRef.current) return reattached.sessionId;
|
||||
setCurrentSession(reattached.sessionId);
|
||||
setLiveSessionAttached(true);
|
||||
if (reattached.sessionId !== sid) {
|
||||
publishSessionId(reattached.sessionId);
|
||||
}
|
||||
@ -2124,10 +2128,18 @@ export function CustomAgentChatView({
|
||||
return;
|
||||
}
|
||||
setExternalSessionId(sessionId);
|
||||
setLiveSessionAttached(false);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) {
|
||||
setOpening(false);
|
||||
setError(null);
|
||||
setSavedConversationLoadState("idle");
|
||||
setLiveSessionAttached(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
async function openOrAttach() {
|
||||
@ -2152,6 +2164,8 @@ export function CustomAgentChatView({
|
||||
return;
|
||||
}
|
||||
if (externalSessionId) {
|
||||
setSavedConversationLoadState("idle");
|
||||
setLiveSessionAttached(false);
|
||||
const cached = chatSessionViewCache.get(externalSessionId);
|
||||
if (cached) {
|
||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||
@ -2194,6 +2208,8 @@ export function CustomAgentChatView({
|
||||
}
|
||||
if (!cancelled) {
|
||||
setCurrentSession(null);
|
||||
setExternalSessionId(null);
|
||||
setLiveSessionAttached(false);
|
||||
publishSessionId(null);
|
||||
}
|
||||
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
|
||||
@ -2202,6 +2218,7 @@ export function CustomAgentChatView({
|
||||
sessionId: externalSessionId,
|
||||
error: instrumentationError(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const cacheKey = conversationCacheKey(
|
||||
@ -2217,32 +2234,45 @@ export function CustomAgentChatView({
|
||||
progressIndex: cached.progressIndex ?? new Map(),
|
||||
progressScope: cached.progressScope ?? 0,
|
||||
});
|
||||
if (cached.sessionId) {
|
||||
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
|
||||
applyScrollback: true,
|
||||
}
|
||||
const savedConversationId = activeConversationIdRef.current;
|
||||
if (!savedConversationId) {
|
||||
setSavedConversationLoadState("idle");
|
||||
setLiveSessionAttached(false);
|
||||
if (freshOnOpen) {
|
||||
await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
retryAttachNotFound: true,
|
||||
conversationId: null,
|
||||
});
|
||||
if (cancelled) return;
|
||||
publishSessionId(reattachedSessionId);
|
||||
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
|
||||
timestamp: new Date().toISOString(),
|
||||
execution,
|
||||
sessionId: reattachedSessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSavedConversationLoadState("loading");
|
||||
setLiveSessionAttached(false);
|
||||
try {
|
||||
const page = await conversation.readPage(projectId, savedConversationId, {
|
||||
direction: "backward",
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
applyChatViewState({
|
||||
...emptyChatViewState(),
|
||||
turns: durableHistoryTurns(page.turns),
|
||||
});
|
||||
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;
|
||||
}
|
||||
await recoverStructuredSession({
|
||||
applyScrollback: Boolean(activeConversationIdRef.current),
|
||||
retryAttachNotFound: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
console.debug("[ticket149] openOrAttach:recover:success", {
|
||||
timestamp: new Date().toISOString(),
|
||||
execution,
|
||||
receivedSessionId: externalSessionId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[ticket149] openOrAttach:error", {
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -2273,6 +2303,10 @@ export function CustomAgentChatView({
|
||||
recoverStructuredSession,
|
||||
publishSessionId,
|
||||
applyChatViewState,
|
||||
conversation,
|
||||
projectId,
|
||||
agentId,
|
||||
freshOnOpen,
|
||||
openRetryNonce,
|
||||
]);
|
||||
|
||||
@ -2280,8 +2314,10 @@ export function CustomAgentChatView({
|
||||
() =>
|
||||
supported &&
|
||||
(Boolean(draft.trim()) || attachments.length > 0) &&
|
||||
!opening,
|
||||
[supported, draft, attachments.length, opening],
|
||||
!opening &&
|
||||
savedConversationLoadState !== "error" &&
|
||||
savedConversationLoadState !== "notFound",
|
||||
[supported, draft, attachments.length, opening, savedConversationLoadState],
|
||||
);
|
||||
const unavailableLinkedTranscript =
|
||||
displayTurns.length === 0 &&
|
||||
@ -2289,16 +2325,17 @@ export function CustomAgentChatView({
|
||||
transcriptUnavailableReason === "conversationNotFound")
|
||||
? transcriptUnavailableReason
|
||||
: null;
|
||||
|
||||
function unlinkUnavailableConversation() {
|
||||
onConversationIdRef.current(null);
|
||||
setTranscriptUnavailableReason("emptyConversation");
|
||||
setError(null);
|
||||
}
|
||||
const savedConversationLoadFailure =
|
||||
savedConversationLoadState === "error" || savedConversationLoadState === "notFound"
|
||||
? savedConversationLoadState
|
||||
: null;
|
||||
|
||||
function retryUnavailableTranscript() {
|
||||
const sid = sessionRef.current;
|
||||
setTranscriptUnavailableReason(null);
|
||||
setSavedConversationLoadState(
|
||||
!sid && activeConversationIdRef.current ? "loading" : "idle",
|
||||
);
|
||||
setError(null);
|
||||
if (sid) {
|
||||
// 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);
|
||||
setCurrentSession(null);
|
||||
setExternalSessionId(null);
|
||||
setLiveSessionAttached(false);
|
||||
publishSessionId(null);
|
||||
publishConversationId(nextConversationId);
|
||||
applyChatViewState(emptyChatViewState());
|
||||
setSavedConversationLoadState("loading");
|
||||
setResumedConversation(false);
|
||||
setConversationNotice(null);
|
||||
setHistoryHasMore(false);
|
||||
@ -2492,6 +2531,7 @@ export function CustomAgentChatView({
|
||||
(await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
retryAttachNotFound: true,
|
||||
...(invocation.name === "/clean" ? { conversationId: null } : {}),
|
||||
}));
|
||||
const result = await agent.executeSlashCommand(invocation.name, {
|
||||
sessionId: sid,
|
||||
@ -2513,6 +2553,7 @@ export function CustomAgentChatView({
|
||||
setExternalSessionId(result.effect.sessionId);
|
||||
publishSessionId(result.effect.sessionId);
|
||||
publishConversationId(result.effect.conversationId ?? null);
|
||||
setSavedConversationLoadState("idle");
|
||||
setResumedConversation(false);
|
||||
setHistoryHasMore(false);
|
||||
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"
|
||||
onScroll={threadScroll.onScroll}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<AnnouncementsPreview
|
||||
requester={agentId}
|
||||
fallbackActive={delegationActive}
|
||||
fallbackText={delegationFallbackText}
|
||||
/>
|
||||
</div>
|
||||
{liveSessionAttached && delegationActive && (
|
||||
<div className="shrink-0">
|
||||
<AnnouncementsPreview
|
||||
requester={agentId}
|
||||
fallbackActive
|
||||
fallbackText={delegationFallbackText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{conversationNotice && (
|
||||
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
|
||||
{conversationNotice}
|
||||
@ -3291,18 +3334,26 @@ export function CustomAgentChatView({
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<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>
|
||||
</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">
|
||||
<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.")}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{unavailableLinkedTranscript === "conversationNotFound"
|
||||
? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.")
|
||||
: tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")}
|
||||
{savedConversationLoadFailure === "notFound" ||
|
||||
unavailableLinkedTranscript === "conversationNotFound"
|
||||
? tr(
|
||||
"Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.",
|
||||
"This saved conversation could not be found. You can retry or open a new conversation.",
|
||||
)
|
||||
: 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>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
@ -3313,11 +3364,18 @@ export function CustomAgentChatView({
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
|
||||
{tr("Délier la conversation", "Unlink conversation")}
|
||||
<Button size="sm" variant="secondary" onClick={() => void startNewConversation()}>
|
||||
{tr("Nouvelle conversation", "New conversation")}
|
||||
</Button>
|
||||
</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 ? (
|
||||
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
|
||||
<strong className="block text-sm text-content">
|
||||
@ -3565,7 +3623,7 @@ export function CustomAgentChatView({
|
||||
)}
|
||||
rows={2}
|
||||
value={draft}
|
||||
disabled={!supported || opening}
|
||||
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
|
||||
placeholder={t("custom.message")}
|
||||
style={{ height: clampComposerHeight(composerHeightPx) }}
|
||||
onChange={(e) => editDraft(e.target.value)}
|
||||
@ -3623,7 +3681,7 @@ export function CustomAgentChatView({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
disabled={!supported || opening}
|
||||
disabled={!supported || opening || Boolean(savedConversationLoadFailure)}
|
||||
onClick={() => void pickAttachment()}
|
||||
>
|
||||
{t("custom.attach")}
|
||||
|
||||
@ -47,6 +47,7 @@ import type { AgentProfile } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockConversationGateway,
|
||||
MockLayoutGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
@ -97,6 +98,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const conversation = new MockConversationGateway();
|
||||
await profileGateway.configureProfiles([profile]);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "Worker",
|
||||
@ -116,6 +118,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
|
||||
terminal,
|
||||
system,
|
||||
workState,
|
||||
conversation,
|
||||
} as unknown as Gateways;
|
||||
}
|
||||
|
||||
@ -175,6 +178,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||||
target: { value: "hello there" },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByText("Envoyer"));
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const conversation = new MockConversationGateway();
|
||||
await profileGateway.configureProfiles([structuredProfile]);
|
||||
|
||||
const agentA = await agent.createAgent("p1", {
|
||||
@ -229,6 +236,15 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
target: leafA.id,
|
||||
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", {
|
||||
name: "Worker B",
|
||||
@ -249,17 +265,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
terminal,
|
||||
system,
|
||||
workState,
|
||||
conversation,
|
||||
} as unknown as Gateways;
|
||||
const launchChat = vi.spyOn(agent, "launchAgentChat");
|
||||
const view = renderGrid(gateways, "p1");
|
||||
|
||||
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
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-/);
|
||||
});
|
||||
expect(await screen.findByText("Question projet A")).toBeTruthy();
|
||||
expect(screen.getByText("Réponse projet A")).toBeTruthy();
|
||||
const persistedA = leaves(await layout.loadLayout("p1"))[0];
|
||||
|
||||
view.rerender(
|
||||
@ -280,10 +293,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
|
||||
.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];
|
||||
expect(restoredA.preferredView).toBe("chat");
|
||||
expect(restoredA.session).toBe(persistedA.session);
|
||||
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 () => {
|
||||
@ -343,7 +359,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
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 layout = gateways.layout as MockLayoutGateway;
|
||||
const workState = gateways.workState as MockWorkStateGateway;
|
||||
@ -386,13 +402,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
const view = renderGrid(gateways);
|
||||
|
||||
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();
|
||||
renderGrid(gateways);
|
||||
|
||||
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 () => {
|
||||
@ -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 agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
@ -476,17 +492,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
|
||||
);
|
||||
await waitFor(() => expect(launchChat).toHaveBeenCalled());
|
||||
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
|
||||
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||
expect(launchChat).not.toHaveBeenCalled();
|
||||
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 agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const conversation = new MockConversationGateway();
|
||||
await profileGateway.configureProfiles([structuredProfile]);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "Worker",
|
||||
@ -505,6 +522,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
conversationId: "conversation-known",
|
||||
});
|
||||
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({
|
||||
sessionId: "chat-fresh",
|
||||
cellKind: "chat" as const,
|
||||
@ -521,27 +542,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
profile: profileGateway,
|
||||
terminal,
|
||||
system,
|
||||
conversation,
|
||||
} as unknown as Gateways);
|
||||
|
||||
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||
expect(screen.queryByText("known prompt")).toBeNull();
|
||||
expect(launchChat).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
created.id,
|
||||
expect.objectContaining({
|
||||
nodeId: leafId,
|
||||
conversationId: undefined,
|
||||
}),
|
||||
);
|
||||
expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function));
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
const leaf = leaves(updated)[0];
|
||||
expect(leaf.conversationId).toBe("conversation-fresh");
|
||||
expect(leaf.session).toBe("chat-fresh");
|
||||
});
|
||||
expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée."))
|
||||
.toBeTruthy();
|
||||
expect(await screen.findByText("known prompt")).toBeTruthy();
|
||||
expect(screen.getByText("known response")).toBeTruthy();
|
||||
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
|
||||
expect(launchChat).not.toHaveBeenCalled();
|
||||
expect(reattachChat).not.toHaveBeenCalled();
|
||||
const updated = await layout.loadLayout("p1");
|
||||
expect(leaves(updated)[0].conversationId).toBe("conversation-known");
|
||||
expect(leaves(updated)[0].session).toBeNull();
|
||||
});
|
||||
|
||||
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||
@ -646,6 +657,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
await waitFor(() =>
|
||||
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 () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
|
||||
@ -701,6 +716,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
);
|
||||
expect(stopLive).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());
|
||||
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
|
||||
await waitFor(async () => {
|
||||
|
||||
@ -1695,7 +1695,6 @@ function LeafView({
|
||||
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
||||
<CustomAgentChatView
|
||||
key={`${id}-${agentId}-custom`}
|
||||
freshOnOpen
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
agentName={customCliAgent.name}
|
||||
|
||||
@ -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", () => {
|
||||
it("mounts and renders the cell without throwing (headless-safe)", async () => {
|
||||
const { agent, profile, agentId } = await seededAgentGateway();
|
||||
@ -113,6 +119,7 @@ describe("WebAgentCell", () => {
|
||||
|
||||
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("terminal-key-bar")).toBeNull();
|
||||
await startCustomConversation();
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
|
||||
expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId");
|
||||
@ -128,6 +135,7 @@ describe("WebAgentCell", () => {
|
||||
nodeId: leafNodeId,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
await startCustomConversation();
|
||||
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
|
||||
@ -162,6 +170,7 @@ describe("WebAgentCell", () => {
|
||||
|
||||
renderCell(agent, profile, agentId, { fullHeight: true });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
await startCustomConversation();
|
||||
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1));
|
||||
@ -186,6 +195,7 @@ describe("WebAgentCell", () => {
|
||||
|
||||
renderCell(agent, profile, agentId, { fullHeight: true });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
await startCustomConversation();
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
const sessionId = (await launchChatSpy.mock.results[0].value).sessionId;
|
||||
|
||||
@ -197,7 +207,7 @@ describe("WebAgentCell", () => {
|
||||
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 launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({
|
||||
code: "PROCESS",
|
||||
@ -207,14 +217,14 @@ describe("WebAgentCell", () => {
|
||||
|
||||
renderCell(agent, profile, agentId, { fullHeight: true });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
|
||||
await startCustomConversation();
|
||||
|
||||
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
|
||||
expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId");
|
||||
expect(reattachChatSpy).not.toHaveBeenCalled();
|
||||
const alertText = (await screen.findByRole("alert")).textContent ?? "";
|
||||
expect(alertText).toContain("Le CLI custom n'a pas pu démarrer");
|
||||
expect(alertText).not.toContain("invalid node id");
|
||||
expect(alertText).toContain("invalid node id: web-mobile-agent-1");
|
||||
});
|
||||
|
||||
it("falls back to native TUI when the mobile agent loses custom CLI support", async () => {
|
||||
|
||||
@ -200,7 +200,6 @@ export function WebAgentCell({
|
||||
)}
|
||||
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
|
||||
<CustomAgentChatView
|
||||
freshOnOpen
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
agentName={resolvedAgent.name}
|
||||
|
||||
Reference in New Issue
Block a user