From 5254f16095b93bc5a5dbdd683b53b9815cf60c75 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 15 Jul 2026 23:17:24 +0200 Subject: [PATCH] feat(frontend): surface agent web sur WebSocket (cellule agent) (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/adapters/http/agentGateway.test.ts | 172 ++++++++++++++++++ frontend/src/adapters/http/streamGateways.ts | 43 ++--- frontend/src/adapters/http/wsLiveClient.ts | 32 ++++ frontend/src/features/web/WebAgentCell.tsx | 76 ++++++++ frontend/src/features/web/WebApp.test.tsx | 15 ++ frontend/src/features/web/WebWorkspace.tsx | 52 +++++- frontend/src/features/web/index.ts | 1 + 7 files changed, 359 insertions(+), 32 deletions(-) create mode 100644 frontend/src/adapters/http/agentGateway.test.ts create mode 100644 frontend/src/features/web/WebAgentCell.tsx diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts new file mode 100644 index 0000000..948864d --- /dev/null +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -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> { + 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" }); + }); +}); diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 61ecd13..e3474bf 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -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 { - // 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 { - 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, }; } } diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index a5e5f07..92d1d9c 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -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 { + 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; diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx new file mode 100644 index 0000000..992c628 --- /dev/null +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -0,0 +1,76 @@ +/** + * Web agent cell — ticket #13, lot F4. Thin wiring (no new terminal logic) that + * reuses the existing, transport-neutral {@link TerminalView} to render a CLI + * agent over the WebSocket in web mode. + * + * The agent's CLI runs server-side (B6); the browser is display-only. This + * component just supplies `TerminalView` with the DI **agent gateway** as the + * opener/reattacher: + * - `open` → `agent.launchAgent(projectId, agentId, …)` (frame `agent.launch`, + * unified `terminal.attached` ack; the minted conversation id is carried on the + * returned handle), + * - `reattach` → `agent.reattach(sessionId, …)` (frame `terminal.attach`, no + * relaunch, bounded scrollback repainted) — so a browser reload/reconnect + * resumes the surviving server-side PTY. + * + * The session id is persisted in component state so a re-mount re-attaches rather + * than relaunching, matching the desktop cell's lifecycle. A structured agent is + * refused server-side (`UNSUPPORTED`) and surfaced by `TerminalView`'s own error + * banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`). + */ + +import { useCallback, useState } from "react"; + +import type { + OpenTerminalOptions, + ReattachResult, + TerminalHandle, +} from "@/ports"; +import { useGateways } from "@/app/di"; +import { TerminalView } from "@/features/terminals"; + +interface WebAgentCellProps { + /** Owning project id (resolved server-side by `agent.launch`). */ + projectId: string; + /** Agent to launch/attach. */ + agentId: string; + /** Working directory for the cell (typically the project root). */ + cwd: string; + /** Layout leaf id, when the caller tracks one (drives the singleton guard). */ + nodeId?: string; +} + +export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) { + const { agent } = useGateways(); + const [sessionId, setSessionId] = useState(null); + + const open = useCallback( + (options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise => + agent.launchAgent( + projectId, + agentId, + nodeId ? { ...options, nodeId } : options, + onData, + ), + [agent, projectId, agentId, nodeId], + ); + + const reattach = useCallback( + (sid: string, onData: (bytes: Uint8Array) => void): Promise => + agent.reattach(sid, onData), + [agent], + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/features/web/WebApp.test.tsx b/frontend/src/features/web/WebApp.test.tsx index 7ae53ef..f4d3491 100644 --- a/frontend/src/features/web/WebApp.test.tsx +++ b/frontend/src/features/web/WebApp.test.tsx @@ -105,6 +105,21 @@ describe("WebApp pairing routing", () => { expect(snapshot.textContent).toContain("idle"); }); + it("opens a live agent cell for a work-state agent (F4 affordance)", async () => { + const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); + session.markPaired(); + renderWebApp(session, await seededGateways()); + + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + + // The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell, + // wired to the DI agent gateway (the CLI runs server-side). + expect(screen.queryByTestId("web-agent-cell")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); + expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); + }); + it("returns to pairing when the session reports unauthorized (401)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index c376f1e..7797cf1 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -17,6 +17,7 @@ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, Project, ProjectWorkState } from "@/domain"; import { useGateways } from "@/app/di"; import { Button, Panel, Spinner } from "@/shared"; +import { WebAgentCell } from "./WebAgentCell"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -32,6 +33,10 @@ export function WebWorkspace() { const [openId, setOpenId] = useState(null); const [snapshot, setSnapshot] = useState(null); const [loadingSnapshot, setLoadingSnapshot] = useState(false); + // The agent currently opened in a live cell (CLI streamed over the WS), if any. + const [openAgentId, setOpenAgentId] = useState(null); + + const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; const refresh = useCallback(async () => { setError(null); @@ -52,6 +57,7 @@ export function WebWorkspace() { setLoadingSnapshot(true); setOpenId(projectId); setSnapshot(null); + setOpenAgentId(null); try { // Read-only: open resolves the project server-side, then we read the // live/work-state snapshot. No layout/agents/PTY are mounted. @@ -117,7 +123,11 @@ export function WebWorkspace() { {loadingSnapshot && } {snapshot ? ( - + ) : loadingSnapshot ? (

Chargement de l'état…

) : ( @@ -125,19 +135,45 @@ export function WebWorkspace() { )} )} + + {openId && openRoot && openAgentId && ( + +
+

Agent

+ +
+ {/* Re-mount the cell per agent so a switch relaunches/reattaches cleanly. */} + +
+ )} ); } -/** Pure render of the read-only work-state snapshot. */ -function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) { +/** Pure render of the read-only work-state snapshot + per-agent "open" affordance. */ +function WorkStateSnapshot({ + snapshot, + openAgentId, + onOpenAgent, +}: { + snapshot: ProjectWorkState; + openAgentId: string | null; + onOpenAgent: (agentId: string) => void; +}) { if (snapshot.agents.length === 0) { return

Aucun agent actif.

; } return (
    {snapshot.agents.map((a) => ( -
  • +
  • {a.name} {a.live ? `live · ${a.live.kind}` : "offline"} @@ -148,6 +184,14 @@ function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) { > {a.busy.state === "busy" ? "busy" : "idle"} +
  • ))} diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts index c15e2c5..ad46ed4 100644 --- a/frontend/src/features/web/index.ts +++ b/frontend/src/features/web/index.ts @@ -6,3 +6,4 @@ export { WebApp } from "./WebApp"; export { PairingScreen } from "./PairingScreen"; export { WebWorkspace } from "./WebWorkspace"; +export { WebAgentCell } from "./WebAgentCell";