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

@ -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,
};
}
}