fix(frontend): restaure l'historique durable au retour sur une conversation existante sans session live — freshOnOpen réservé au reset explicite (retiré de LayoutGrid et WebAgentCell), conversationId persistant de la cellule réutilisé au remontage ordinaire avec chargement de l'historique via conversation.readPage (machine savedConversationLoadState idle/loading/loaded/error/notFound, pagination ancrée conservée, écran vide faussement « Nouvelle conversation » éliminé), bannière d'annonces live et fallback de délégation masqués sans session réellement rattachée (liveSessionAttached), /clean et le reset ouvrent explicitement une nouvelle conversation (conversationId: null), erreurs de chargement traitées avec réessai et bouton « Nouvelle conversation » à la place du déliage, composer et pièces jointes désactivés en échec de chargement — #366 (QA verte: npm test ciblé CustomAgentChatView + LayoutGrid.chat + WebAgentCell 3 fichiers 119 tests OK, npm run build OK, git diff --check OK; warning jsdom/canvas connu non bloquant, npm audit: vulnérabilités préexistantes non liées)

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

View File

@ -288,30 +288,21 @@ describe("CustomAgentChatView", () => {
expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40");
});
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 () => {

View File

@ -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,
});
if (cancelled) return;
publishSessionId(reattachedSessionId);
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
timestamp: new Date().toISOString(),
execution,
sessionId: reattachedSessionId,
});
return;
}
return;
}
}
const savedConversationId = activeConversationIdRef.current;
if (!savedConversationId) {
setSavedConversationLoadState("idle");
setLiveSessionAttached(false);
if (freshOnOpen) {
await recoverStructuredSession({
applyScrollback: Boolean(activeConversationIdRef.current),
applyScrollback: false,
retryAttachNotFound: true,
conversationId: null,
});
}
return;
}
setSavedConversationLoadState("loading");
setLiveSessionAttached(false);
try {
const page = await conversation.readPage(projectId, savedConversationId, {
direction: "backward",
limit: 50,
});
if (cancelled) return;
console.debug("[ticket149] openOrAttach:recover:success", {
timestamp: new Date().toISOString(),
execution,
receivedSessionId: externalSessionId,
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;
}
} 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}
>
{liveSessionAttached && delegationActive && (
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive={delegationActive}
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")}

View File

@ -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 () => {
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");
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(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 () => {

View File

@ -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}

View File

@ -68,6 +68,12 @@ function renderCell(
);
}
async function startCustomConversation(message = "Start mobile chat") {
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
fireEvent.change(composer, { target: { value: message } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
}
describe("WebAgentCell", () => {
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 () => {

View File

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