Files
IdeaSDK/frontend/src/adapters/http/streamGateways.ts
Blomios 5254f16095 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>
2026-07-15 23:17:24 +02:00

375 lines
14 KiB
TypeScript

/**
* Stream + mixed gateways for the web transport — ticket #13, lot F1.
*
* These gateways combine HTTP request/response (via {@link HttpInvoker}) with the
* WebSocket live streams (via {@link WsLiveClient}), per the B0 contract:
* - {@link HttpSystemGateway}: `health` over HTTP; `onDomainEvent` over the WS
* `event.domain` stream; `pickFolder` is desktop-only ⇒ unsupported on web.
* - {@link HttpAgentGateway}: all agent request/response over HTTP; `launchAgent`
* / `reattach` over the WS PTY stream (**skeleton** — full round-trip in
* F3/B5/B6).
* - {@link HttpTicketGateway}: all `ticket_*`/`sprint_*` over HTTP; `sendTicketChat`
* over the WS `chat.*` stream (**skeleton**).
* - {@link HttpTerminalGateway}: PTY open/reattach/close over the WS terminal
* stream (**skeleton**).
*
* "Skeleton" means the frames are wired to the B0 contract and the handles are
* structurally correct against the ports, but a live end-to-end terminal needs
* the server (B3/B4) and the F3/B5 wiring. Lives in `src/adapters/**`; no
* `@tauri-apps/api`.
*/
import type {
Agent,
DomainEvent,
HealthReport,
ReplyChunk,
ResumableAgent,
Sprint,
TerminalSession,
Ticket,
TicketCarnet,
TicketChat,
TicketLinkKind,
TicketList,
Unsubscribe,
} from "@/domain";
import type {
AgentGateway,
ConversationDetails,
CreateAgentInput,
CreateTicketInput,
LiveAgent,
OpenTerminalOptions,
ReattachResult,
StoppedLiveAgent,
SystemGateway,
TerminalGateway,
TerminalHandle,
TicketGateway,
TicketListQuery,
UpdateTicketInput,
} from "@/ports";
import type { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { type ChatOutputPayload } from "./frames";
import { unsupportedOnWeb } from "./unsupported";
/**
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
* output stream is delivered through the sink the gateway registered on the
* {@link WsLiveClient} for this `sessionId`.
*/
export function makeWsTerminalHandle(
sessionId: string,
ws: WsLiveClient,
assignedConversationId?: string,
): TerminalHandle {
return {
sessionId,
...(assignedConversationId ? { assignedConversationId } : {}),
async write(data: Uint8Array): Promise<void> {
await ws.sendFireAndForget("terminal.input", {
sessionId,
bytesBase64: WsLiveClient.encodeInput(data),
});
},
async resize(rows: number, cols: number): Promise<void> {
await ws.sendFireAndForget("terminal.resize", { sessionId, rows, cols });
},
detach(): void {
// Stop delivering output locally and tell the server the view is gone. The
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
// Untracking also disables reconnection re-attach for this session.
void ws.detachTerminal(sessionId);
},
async close(): Promise<void> {
await ws.closeTerminalSession(sessionId);
},
};
}
export class HttpSystemGateway implements SystemGateway {
constructor(
private readonly http: HttpInvoker,
private readonly ws: WsLiveClient,
) {}
health(note?: string): Promise<HealthReport> {
return this.http.invoke<HealthReport>("health", {
request: note === undefined ? null : { note },
});
}
async onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe> {
// The low-frequency domain-event stream rides the same WS connection
// (`event.domain`), replacing Tauri `listen("domain://event")`.
await this.ws.ensureConnected();
this.ws.setDomainEventHandler(handler);
return () => this.ws.clearDomainEventHandler();
}
pickFolder(): Promise<string | null> {
// Desktop-only: the native OS dialog has no web equivalent. A server-side
// folder browser would be its own lot (flagged in the F1 report).
return unsupportedOnWeb("Native folder picker");
}
}
export class HttpTerminalGateway implements TerminalGateway {
constructor(private readonly ws: WsLiveClient) {}
async openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// B5: `terminal.open` carries no projectId; `cwd` is a server-validated path.
// A fresh open has empty scrollback (the view does not repaint on open).
const res = await this.ws.openTerminal({
cwd: options.cwd,
rows: options.rows,
cols: options.cols,
nodeId: options.nodeId ?? null,
onData,
});
return makeWsTerminalHandle(res.sessionId, this.ws);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
// The view repaints the returned scrollback itself (TerminalView contract);
// the client does not also push it through `onData` on the initial attach.
const res = await this.ws.attachTerminal({ sessionId, onData });
return {
handle: makeWsTerminalHandle(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
async closeTerminal(sessionId: string): Promise<void> {
await this.ws.closeTerminalSession(sessionId);
}
}
export class HttpAgentGateway implements AgentGateway {
constructor(
private readonly http: HttpInvoker,
private readonly ws: WsLiveClient,
) {}
listAgents(projectId: string): Promise<Agent[]> {
return this.http.invoke<Agent[]>("list_agents", { projectId });
}
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
return this.http.invoke<LiveAgent[]>("list_live_agents", { projectId });
}
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
return this.http
.invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId })
.then((res) => res.resumable);
}
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
}
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", { request: { projectId, agentId } });
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
return this.http.invoke<Agent>("create_agent", {
request: {
projectId,
name: input.name,
profileId: input.profileId,
initialContent: input.initialContent ?? null,
},
});
}
changeAgentProfile(
projectId: string,
agentId: string,
profileId: string,
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
return this.http.invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>("change_agent_profile", {
request: { projectId, agentId, profileId, rows, cols },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
}
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
await this.http.invoke("delete_agent", { projectId, agentId });
}
inspectConversation(
projectId: string,
agentId: string,
conversationId: string,
): Promise<ConversationDetails> {
return this.http.invoke<ConversationDetails>("inspect_conversation", {
request: { projectId, agentId, conversationId },
});
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// 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,
});
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
// 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(res.sessionId, this.ws),
scrollback: res.scrollback,
};
}
}
export class HttpTicketGateway implements TicketGateway {
constructor(
private readonly http: HttpInvoker,
private readonly ws: WsLiveClient,
) {}
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_create", { request: { projectId, ...input } });
}
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } });
}
list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const { statuses, priorities, ...rest } = query ?? {};
return this.http.invoke<TicketList>("ticket_list", {
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
});
}
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } });
}
async delete(projectId: string, ref: string): Promise<void> {
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
}
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
}
updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_update_carnet", {
request: { projectId, ref, carnet, expectedVersion },
});
}
link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_link", {
request: { projectId, ref, targetRef, kind, expectedVersion },
});
}
unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_unlink", {
request: { projectId, ref, targetRef, expectedVersion, kind },
});
}
assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
return this.http.invoke<Ticket>("ticket_assign", {
request: { projectId, ref, agentId, assigned, expectedVersion },
});
}
async listSprints(projectId: string): Promise<Sprint[]> {
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } });
return list.items;
}
setTicketSprint(
projectId: string,
ref: string,
sprintId: string | null,
expectedVersion: number,
): Promise<Ticket> {
if (sprintId === null) {
return this.http.invoke<Ticket>("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } });
}
return this.http.invoke<Ticket>("ticket_assign_sprint", {
request: { projectId, ref, sprintId, expectedVersion },
});
}
createSprint(projectId: string, name: string): Promise<Sprint> {
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });
}
renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise<Sprint> {
return this.http.invoke<Sprint>("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } });
}
async reorderSprints(projectId: string, orderedIds: string[]): Promise<Sprint[]> {
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", {
request: { projectId, orderedIds },
});
return list.items;
}
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
await this.http.invoke<void>("sprint_delete", { request: { projectId, sprintId } });
}
openTicketChat(projectId: string, issueRef: string, profileId: string): Promise<TicketChat> {
return this.http.invoke<TicketChat>("open_ticket_chat", { request: { projectId, issueRef, profileId } });
}
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
await this.http.invoke<void>("close_ticket_chat", { request: { projectId, issueRef } });
}
async sendTicketChat(
sessionId: string,
message: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
// Structured assistant reply stream over WS (`chat.*`, B0). Skeleton: route
// this session's `chat.output` chunks to `onChunk` until the `final` chunk.
// TODO(F3/B5): the WsLiveClient currently routes PTY output + domain events;
// a per-session chat sink is added alongside the terminal sink in F3.
const ack = await this.ws.send("chat.send", { sessionId, prompt: message });
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
if (payload && payload.chunk) onChunk(payload.chunk as ReplyChunk);
}
}