feat(frontend): surface agent web sur WebSocket (cellule agent) (#13)

Lot F4 du chantier server/client mode : le client web expose une cellule
agent branchée sur le PTY WebSocket, en face de agent.launch (B6).

- wsLiveClient.ts : launchAgent sur le transport WebSocket.
- streamGateways.ts : gateway agent alignée sur le contrat serveur B6.
- WebAgentCell.tsx : cellule agent web, câblée dans WebWorkspace et index.
- Tests : agentGateway.test.ts, WebApp.test.tsx.

Validé : frontend 749 tests verts, contrat B6↔F4 aligné (aucun écart de
frame), desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 23:17:24 +02:00
parent 6391d1c75a
commit 5254f16095
7 changed files with 359 additions and 32 deletions

View File

@ -0,0 +1,172 @@
/**
* F4 — the WS agent gateway round-trip against the B6 frame contract:
* `agent.launch` → unified `terminal.attached` (sessionId + assignedConversationId
* consumed), then output/input/resize reuse the terminal mechanics; agent
* re-attach uses `terminal.attach` (no relaunch) and returns scrollback; a
* structured agent (`UNSUPPORTED`) and a dead session (`NOT_FOUND`) reject clearly.
*/
import { describe, it, expect, vi } from "vitest";
import { HttpAgentGateway } from "./streamGateways";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
import { bytesToBase64 } from "./frames";
class FakeSocket implements WebSocketLike {
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.onclose?.();
}
receive(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
}
function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
queueMicrotask(() => s.onopen?.());
return s;
},
});
// The HTTP invoker is unused by the WS paths under test.
const gw = new HttpAgentGateway(new HttpInvoker({ baseUrl: "https://host" }), ws);
return { gw, sockets };
}
async function replyToLast(
socket: FakeSocket,
index: number,
reply: (id: string) => unknown,
): Promise<Record<string, unknown>> {
await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index));
const sent = JSON.parse(socket.sent[index]);
socket.receive(reply(sent.id));
return sent;
}
function attachedAck(
id: string,
sessionId: string,
opts: { scroll?: Uint8Array; assignedConversationId?: string } = {},
) {
return {
kind: "terminal.attached",
replyTo: id,
payload: {
session: { sessionId, rows: 24, cols: 80 },
scrollback: opts.scroll ? [{ seq: 0, bytesBase64: bytesToBase64(opts.scroll) }] : [],
nextSeq: opts.scroll ? 1 : 0,
status: "running",
gap: false,
assignedConversationId: opts.assignedConversationId,
},
};
}
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];
const handlePromise = gw.launchAgent("proj-1", "agent-1", { ...OPTS, conversationId: "resume-9" }, (b) =>
chunks.push(b),
);
const sent = await replyToLast(sockets[0], 0, (id) =>
attachedAck(id, "sess-1", { assignedConversationId: "conv-1" }),
);
const handle = await handlePromise;
// B6 payload: flat camelCase, no cwd (agent launch resolves the project).
expect(sent.kind).toBe("agent.launch");
expect(sent.payload).toEqual({
projectId: "proj-1",
agentId: "agent-1",
nodeId: "node-1",
rows: 24,
cols: 80,
conversationId: "resume-9",
});
expect(handle.sessionId).toBe("sess-1");
// The conversation id minted by the launch is surfaced on the handle.
expect(handle.assignedConversationId).toBe("conv-1");
// Fresh launch: nothing repainted.
expect(chunks).toHaveLength(0);
// Output/input/resize reuse the terminal mechanics.
sockets[0].receive({
kind: "terminal.output",
payload: { sessionId: "sess-1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([65])) },
});
expect(chunks).toEqual([new Uint8Array([65])]);
const base = sockets[0].sent.length;
await handle.write(new Uint8Array([13]));
await handle.resize(30, 100);
expect(JSON.parse(sockets[0].sent[base]).kind).toBe("terminal.input");
expect(JSON.parse(sockets[0].sent[base + 1])).toMatchObject({
kind: "terminal.resize",
payload: { sessionId: "sess-1", rows: 30, cols: 100 },
});
});
it("omits assignedConversationId on the handle when the launch minted none", async () => {
const { gw, sockets } = gateway();
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-x"));
const handle = await handlePromise;
expect(handle.assignedConversationId).toBeUndefined();
});
it("re-attaches to an existing agent via terminal.attach (no relaunch) and returns scrollback", async () => {
const { gw, sockets } = gateway();
const chunks: Uint8Array[] = [];
const scroll = new Uint8Array([104, 105]);
const resPromise = gw.reattach("sess-7", (b) => chunks.push(b));
const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "sess-7", { scroll }));
const res = await resPromise;
// Re-attach must NOT relaunch: the frame is terminal.attach, not agent.launch.
expect(sent.kind).toBe("terminal.attach");
expect(sent.payload).toMatchObject({ sessionId: "sess-7" });
expect(res.scrollback).toEqual(scroll);
// Scrollback returned for the view to repaint, not double-pushed via onData.
expect(chunks).toHaveLength(0);
});
it("rejects a structured agent with UNSUPPORTED (no crash)", async () => {
const { gw, sockets } = gateway();
const handlePromise = gw.launchAgent("p", "a", OPTS, () => {});
await replyToLast(sockets[0], 0, (id) => ({
kind: "error",
replyTo: id,
payload: { code: "UNSUPPORTED", message: "structured agent sessions do not stream over the PTY websocket" },
}));
await expect(handlePromise).rejects.toMatchObject({ code: "UNSUPPORTED" });
});
it("rejects re-attach to a missing/exited session with NOT_FOUND", async () => {
const { gw, sockets } = gateway();
const resPromise = gw.reattach("gone", () => {});
await replyToLast(sockets[0], 0, (id) => ({
kind: "error",
replyTo: id,
payload: { code: "NOT_FOUND", message: "terminal session not found" },
}));
await expect(resPromise).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});

View File

@ -52,22 +52,9 @@ import type {
} from "@/ports";
import type { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
import { type ChatOutputPayload } from "./frames";
import { unsupportedOnWeb } from "./unsupported";
/** Concatenates a scrollback frame list into a single byte buffer. */
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
}
/**
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
* output stream is delivered through the sink the gateway registered on the
@ -235,34 +222,34 @@ export class HttpAgentGateway implements AgentGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
// sessions do not use this channel — that branch is B6.
const ack = await this.ws.send("agent.launch", {
// B6: a raw-CLI agent streams over the same PTY channel as a terminal. The
// WS client tracks the session (reconnection/replay/status parity with F3);
// the unified `terminal.attached` ack yields the sessionId + the conversation
// id minted by this launch. Structured agents are refused server-side with
// `UNSUPPORTED` — the rejection propagates to the caller (a clear cell error,
// no crash). A fresh launch has empty scrollback (no repaint on open).
const res = await this.ws.launchAgent({
projectId,
agentId,
nodeId: options.nodeId ?? null,
rows: options.rows,
cols: options.cols,
conversationId: options.conversationId ?? null,
onData,
});
const payload = ack.payload as unknown as AttachedPayload;
const sessionId = payload.session.sessionId;
this.ws.setOutputSink(sessionId, onData);
const scrollback = attachedToScrollback(payload);
if (scrollback.length > 0) onData(scrollback);
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
const payload = ack.payload as unknown as AttachedPayload;
this.ws.setOutputSink(sessionId, onData);
// Agent sessions re-attach through the same `terminal.attach` mechanics as a
// terminal — no relaunch, bounded scrollback replayed for the view to repaint.
const res = await this.ws.attachTerminal({ sessionId, onData });
return {
handle: makeWsTerminalHandle(sessionId, this.ws),
scrollback: attachedToScrollback(payload),
handle: makeWsTerminalHandle(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
}

View File

@ -340,6 +340,38 @@ export class WsLiveClient {
);
}
/**
* Launches a CLI agent (`agent.launch`) over the same PTY channel and tracks
* it exactly like a terminal (B6): the ack is a unified `terminal.attached`
* carrying `sessionId` + `assignedConversationId`, then output/input/resize and
* reconnection replay reuse the terminal mechanics. Structured agents are
* refused server-side with `UNSUPPORTED` (this channel is PTY-only); the
* rejected `send` surfaces that error to the caller unchanged.
*/
launchAgent(params: {
projectId: string;
agentId: string;
rows: number;
cols: number;
nodeId?: string | null;
conversationId?: string | null;
onData: (bytes: Uint8Array) => void;
onStatus?: (status: string, exitCode?: number | null) => void;
}): Promise<TerminalAttachResult> {
return this.attachInternal(
"agent.launch",
{
projectId: params.projectId,
agentId: params.agentId,
nodeId: params.nodeId ?? null,
rows: params.rows,
cols: params.cols,
conversationId: params.conversationId ?? null,
},
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
);
}
/** Re-attaches to an existing terminal (`terminal.attach`) and tracks it. */
attachTerminal(params: {
sessionId: string;