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");
|
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 () => {
|
||||||
|
|||||||
@ -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, {
|
const savedConversationId = activeConversationIdRef.current;
|
||||||
applyScrollback: true,
|
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;
|
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) {
|
} 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}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
{liveSessionAttached && delegationActive && (
|
||||||
<AnnouncementsPreview
|
<div className="shrink-0">
|
||||||
requester={agentId}
|
<AnnouncementsPreview
|
||||||
fallbackActive={delegationActive}
|
requester={agentId}
|
||||||
fallbackText={delegationFallbackText}
|
fallbackActive
|
||||||
/>
|
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")}
|
||||||
|
|||||||
@ -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({
|
const updated = await layout.loadLayout("p1");
|
||||||
nodeId: leafId,
|
expect(leaves(updated)[0].conversationId).toBe("conversation-known");
|
||||||
conversationId: undefined,
|
expect(leaves(updated)[0].session).toBeNull();
|
||||||
}),
|
|
||||||
);
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user