fix(web): le client WS réattache les sessions chat structurées après reconnexion — #241

needsReconnect() couvre désormais les chatSinks et le cycle de reconnexion
renvoie un chat.attach par session active, un échec isolé n'empêchant pas
les autres abonnements de reprendre. Tests ciblés ajoutés.
This commit is contained in:
2026-08-23 10:24:29 +02:00
parent 6fe7f99844
commit 738e15a09b
2 changed files with 131 additions and 3 deletions

View File

@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import type { WebSocketLike } from "./wsLiveClient";
import { WsLiveClient } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
@ -33,6 +34,32 @@ function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
return { client, socket };
}
function attachedAck(id: string, sessionId: string) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 30, cols: 120 },
scrollback: [],
nextSeq: 0,
status: "running",
gap: false,
},
};
}
async function attachTerminal(
client: WsLiveClient,
sockets: FakeSocket[],
sessionId: string,
): Promise<void> {
const attached = client.attachTerminal({ sessionId, onData: () => {} });
await vi.waitFor(() => expect(sockets[0]?.sent.length ?? 0).toBeGreaterThan(0));
const sent = JSON.parse(sockets[0].sent[0]);
sockets[0].receive(attachedAck(sent.id, sessionId));
await attached;
}
describe("WsLiveClient structured chat routing", () => {
it("routes chat.output to the per-session chat sink", async () => {
const { client, socket } = connectedClient();
@ -80,4 +107,92 @@ describe("WsLiveClient structured chat routing", () => {
});
expect(received).toEqual([]);
});
it("re-attaches chat sinks after reconnect while PTY logs keep the socket alive", async () => {
const sockets: FakeSocket[] = [];
let timerFn: (() => void) | null = null;
const client = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
socketFactory: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.onopen?.());
return socket;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
await attachTerminal(client, sockets, "pty-1");
const received: unknown[] = [];
client.setChatSink("chat-1", (chunk) => received.push(chunk));
sockets[0].close();
expect(client.getConnectionState()).toBe("reconnecting");
expect(client.needsReconnect()).toBe(true);
const fire = timerFn;
timerFn = null;
fire?.();
await vi.waitFor(() => expect(sockets.length).toBe(2));
await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(0));
const terminalAttach = JSON.parse(sockets[1].sent[0]);
expect(terminalAttach.kind).toBe("terminal.attach");
sockets[1].receive(attachedAck(terminalAttach.id, "pty-1"));
await vi.waitFor(() => expect(sockets[1].sent.length).toBeGreaterThan(1));
const chatAttach = JSON.parse(sockets[1].sent[1]);
expect(chatAttach.kind).toBe("chat.attach");
expect(chatAttach.payload).toEqual({ sessionId: "chat-1" });
sockets[1].receive({
kind: "chat.attached",
replyTo: chatAttach.id,
payload: { sessionId: "chat-1", cellKind: "chat" },
});
sockets[1].receive({
kind: "terminal.output",
payload: {
sessionId: "pty-1",
seq: 1,
bytesBase64: bytesToBase64(new Uint8Array([108, 111, 103])),
},
});
sockets[1].receive({
kind: "chat.output",
payload: {
sessionId: "chat-1",
chunk: {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
text: "reading ticket #241",
},
},
},
});
expect(received).toEqual([
{
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
text: "reading ticket #241",
},
},
]);
});
});