merge(web): progress custom CLI invisibles après plusieurs demandes — #241 (QA verte)
This commit is contained in:
@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -294,7 +294,8 @@ export class WsLiveClient {
|
||||
}
|
||||
this.setConnState("reconnecting");
|
||||
// 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.
|
||||
if (this.terminals.size > 0) {
|
||||
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). */
|
||||
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 {
|
||||
@ -354,6 +359,14 @@ export class WsLiveClient {
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user