/** * Tauri adapter for {@link AgentGateway} (L6). * * NOTE: The Tauri commands wired here (`list_agents`, `create_agent`, …) are * defined in the backend `app-tauri` crate and will be registered in a * subsequent lot. This adapter is complete on the frontend side; the mock * gateway covers tests and offline dev today. The real mode will work * transparently once the commands are registered. * * Commands use snake_case (Tauri convention); payload keys are camelCase * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent * with the other adapters in this directory. */ import { Channel, invoke } from "@tauri-apps/api/core"; import type { Agent, ResumableAgent, TerminalSession, } from "@/domain"; import type { AgentGateway, ConversationDetails, CreateAgentInput, LiveAgent, OpenTerminalOptions, ReattachResult, StoppedLiveAgent, TerminalHandle, } from "@/ports"; import { makeTerminalHandle } from "./terminal"; /** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */ interface LaunchAgentResponse { sessionId: string; cwd: string; rows: number; cols: number; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; } export class TauriAgentGateway implements AgentGateway { listAgents(projectId: string): Promise { return invoke("list_agents", { projectId }); } listLiveAgents(projectId: string): Promise { return invoke("list_live_agents", { projectId }); } listResumableAgents(projectId: string): Promise { // `list_resumable_agents` takes a top-level `projectId` (camelCase) and // returns `{ resumable: ResumableAgentDto[] }`. The DTO is already camelCase // and shape-compatible with `ResumableAgent`, so we just unwrap the list. return invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId, }).then((res) => res.resumable); } attachLiveAgent( projectId: string, agentId: string, nodeId: string, ): Promise { return invoke("attach_live_agent", { request: { projectId, agentId, nodeId }, }); } stopLiveAgent(projectId: string, agentId: string): Promise { return invoke("stop_live_agent", { request: { projectId, agentId }, }); } createAgent(projectId: string, input: CreateAgentInput): Promise { // The `create_agent` command takes a single `request` DTO; `projectId` must // live *inside* it (camelCase), not at the top level. return invoke("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 }> { // `change_agent_profile` takes a single `request` DTO; the camelCase keys // match the backend `ChangeAgentProfileRequestDto`. The response carries the // mutated agent and, when a live session was hot-swapped, the relaunched one // (omitted otherwise — `relaunchedSession` stays `undefined`). return invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>( "change_agent_profile", { request: { projectId, agentId, profileId, rows, cols } }, ); } readContext(projectId: string, agentId: string): Promise { return invoke("read_agent_context", { projectId, agentId }); } async updateContext( projectId: string, agentId: string, content: string, ): Promise { // `update_agent_context` takes a single `request` DTO. await invoke("update_agent_context", { request: { projectId, agentId, content }, }); } async deleteAgent(projectId: string, agentId: string): Promise { await invoke("delete_agent", { projectId, agentId }); } async launchAgent( projectId: string, agentId: string, options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { // Per-session output channel. The backend serialises chunks as byte arrays. const channel = new Channel(); channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); const res = await invoke("launch_agent", { request: { projectId, agentId, rows: options.rows, cols: options.cols, // Resume id: the leaf's persisted conversation id, when any (T4b). The // backend resumes it; absent ⇒ a fresh cell that may get a new id. conversationId: options.conversationId ?? null, // Hosting cell: lets the backend enforce one-live-session-per-agent. nodeId: options.nodeId ?? null, }, onOutput: channel, }); const base = makeTerminalHandle(res.sessionId, channel); // Surface the id assigned by this launch (so the caller persists it on the // leaf and resumes next time). return { ...base, ...(res.assignedConversationId ? { assignedConversationId: res.assignedConversationId } : {}), }; } async reattach( sessionId: string, onData: (bytes: Uint8Array) => void, ): Promise { // Agent sessions reattach through the same session-based `reattach_terminal` // command as plain terminals (the PTY is identified by its session id). const channel = new Channel(); channel.onmessage = (chunk) => onData(Uint8Array.from(chunk)); const res = await invoke<{ sessionId: string; scrollback: number[] }>( "reattach_terminal", { sessionId, onOutput: channel }, ); return { handle: makeTerminalHandle(res.sessionId, channel), scrollback: Uint8Array.from(res.scrollback), }; } inspectConversation( projectId: string, agentId: string, conversationId: string, ): Promise { // `inspect_conversation` takes a single `request` DTO; absent fields are // omitted from the response (best-effort), so an empty object is valid. return invoke("inspect_conversation", { request: { projectId, agentId, conversationId }, }); } }