merge(web): progress custom CLI invisibles après plusieurs demandes — #241 (QA verte)

This commit is contained in:
2026-08-23 10:24:35 +02:00
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 type { WebSocketLike } from "./wsLiveClient";
import { WsLiveClient } from "./wsLiveClient"; import { WsLiveClient } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike { class FakeSocket implements WebSocketLike {
sent: string[] = []; sent: string[] = [];
@ -33,6 +34,32 @@ function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
return { client, socket }; 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", () => { describe("WsLiveClient structured chat routing", () => {
it("routes chat.output to the per-session chat sink", async () => { it("routes chat.output to the per-session chat sink", async () => {
const { client, socket } = connectedClient(); const { client, socket } = connectedClient();
@ -80,4 +107,92 @@ describe("WsLiveClient structured chat routing", () => {
}); });
expect(received).toEqual([]); 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",
},
},
]);
});
}); });

View File

@ -294,7 +294,8 @@ export class WsLiveClient {
} }
this.setConnState("reconnecting"); this.setConnState("reconnecting");
// Reconnect while anything still needs the socket: a live terminal to // Reconnect while anything still needs the socket: a live terminal to
// re-attach (F3) OR an active domain-event subscription (F5 live surfaces). // re-attach (F3), an active structured chat subscription, OR an active
// domain-event subscription (F5 live surfaces).
// Otherwise stay idle until the next explicit use. // Otherwise stay idle until the next explicit use.
if (this.terminals.size > 0) { if (this.terminals.size > 0) {
for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…")); for (const sub of this.terminals.values()) sub.onData(notice("déconnecté — reconnexion…"));
@ -304,7 +305,11 @@ export class WsLiveClient {
/** Whether the socket should auto-reconnect (a terminal or a live subscription). */ /** Whether the socket should auto-reconnect (a terminal or a live subscription). */
needsReconnect(): boolean { needsReconnect(): boolean {
return this.terminals.size > 0 || this.domainEventHandler !== null; return (
this.terminals.size > 0 ||
this.chatSinks.size > 0 ||
this.domainEventHandler !== null
);
} }
private scheduleReconnect(): void { private scheduleReconnect(): void {
@ -354,6 +359,14 @@ export class WsLiveClient {
// block the others; leave it tracked for the next reconnect cycle. // block the others; leave it tracked for the next reconnect cycle.
} }
} }
for (const sessionId of this.chatSinks.keys()) {
try {
await this.send("chat.attach", { sessionId });
} catch {
// Same policy as PTY re-attach: one stale/temporarily unavailable chat
// session must not prevent the other live subscriptions from resuming.
}
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------