fix(chat): le transcript s'affiche à nouveau après redémarrage automatique d'un agent — #295 (app-tauri ChatBridge: cartes successors/owners, rebind_session déplace sink+scrolloback du logical chat vers la session de remplacement et canonical_session résout les écritures avec l'ancien id sur toutes les opérations register/scrollback/detach/send_output; backend stream: ReplayOutputBridge::rekey prépare le scrollback source devant le destination et invalide les générations pré-rekey des deux pumps; commands: agent_send binde l'owner stable (projectId,agentId) au tour humain et rebind avant le flux de continuation au handoff restart; reattach_agent_chat résout le successeur du bridge puis fallback identité stable projectId/agentId explicite ou owner bindé, renvoie sessionId effectif + conversationId, NOT_FOUND sinon; web-server chat.attach même fallback stale→live par project/agent; DTO ReattachChatDto/ReattachResultDto documentent l'id effectif post-restart; frontend: ReattachAgentChatOptions, conversationId dans ChatAttachedPayload, CustomAgentChatView réattache sur agentRestartCompleted (dédupe par restartId, scrollback appliqué, tours restart préservés), retry identité stable sur NOT_FOUND dans openOrAttach avant fallback launch frais, publication sessionId effectif + conversationId, état vide «Conversation liée» quand une conversation/run/workstate lié existe; QA verte reconfirmée par Git: git diff --check, cargo fmt --all --check, app-tauri chat_bridge 34/34 dont 3 nouveaux restart_rebind + dto_chat 24/24, cargo check -p app-tauri, backend 91+7 ignored permissions socket, web-server websocket_chat_attach 3/3, npm test 77/77 CustomAgentChatView+agentGateway, npm run build; réserves non bloquantes: warnings Vite esbuild/oxc et chunk >500kB connus)

This commit is contained in:
2026-09-09 18:44:01 +02:00
parent b1c173f7d0
commit 1b1476de52
14 changed files with 666 additions and 37 deletions

View File

@ -231,6 +231,143 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("runtime unavailable")).not.toBeNull();
});
it("reconciles a restarted chat to its replacement session and keeps receiving chunks", async () => {
const system = new MockSystemGateway();
const liveReceivers: Array<(chunk: ReplyChunk) => void> = [];
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
.fn()
.mockImplementationOnce(
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
liveReceivers.push(onChunk);
return {
sessionId: "old-session",
conversationId: "old-conversation",
scrollback: [{ kind: "final", content: "Avant redémarrage" }],
};
},
)
.mockImplementationOnce(
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
liveReceivers.push(onChunk);
return {
sessionId: "replacement-session",
conversationId: "replacement-conversation",
scrollback: [
{ kind: "final", content: "Avant redémarrage" },
{ kind: "userPrompt", text: "Continue" },
{ kind: "textDelta", text: "Reprise conservée" },
],
};
},
),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
<AnnouncementsProvider projectId="project-1">
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="old-session"
conversationId="old-conversation"
onSessionId={onSessionId}
onConversationId={onConversationId}
/>
</AnnouncementsProvider>
</DIProvider>,
);
expect(await screen.findByText("Avant redémarrage")).toBeTruthy();
act(() => {
system.emit({
type: "agentRestartRequested",
agentId: "agent-1",
restartId: "restart-295",
reason: "reload plugin",
});
system.emit({
type: "agentRestartCompleted",
agentId: "agent-1",
restartId: "restart-295",
});
});
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2));
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"old-session",
expect.any(Function),
{ projectId: "project-1", agentId: "agent-1" },
);
expect(await screen.findByText("Reprise conservée")).toBeTruthy();
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
expect(onConversationId).toHaveBeenCalledWith("replacement-conversation");
act(() => {
liveReceivers[1]?.({ kind: "final", content: "Chunk après redémarrage" });
});
expect(await screen.findByText("Chunk après redémarrage")).toBeTruthy();
expect(screen.queryByText(/Envoyez un message/)).toBeNull();
});
it("retries a stale session attach with stable project and agent identity", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "stale session" })
.mockResolvedValueOnce({
sessionId: "replacement-session",
conversationId: "replacement-conversation",
scrollback: [{ kind: "final", content: "Transcript restauré" }],
}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{ agent, system: new MockSystemGateway() } as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect(await screen.findByText("Transcript restauré")).toBeTruthy();
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"stale-session",
expect.any(Function),
{ projectId: "project-1", agentId: "agent-1" },
);
expect(agent.launchAgentChat).not.toHaveBeenCalled();
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
});
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
const openNativeTui = vi.fn();
const agent = {
@ -1344,6 +1481,7 @@ 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: [],
@ -4829,6 +4967,9 @@ describe("CustomAgentChatView", () => {
.mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
)
.mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
)
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
@ -4869,6 +5010,12 @@ describe("CustomAgentChatView", () => {
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"69b57638-0ac7-4a79-8252-e0436a3265f6",
expect.any(Function),
{ projectId: "project-1", agentId: "agent-1" },
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
3,
"chat-session-2",
expect.any(Function),
);