Files
IdeA/frontend/src/adapters/agent.ts

376 lines
11 KiB
TypeScript

/**
* 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,
AgentContextDocument,
EffortSelection,
GatewayError,
ExecuteSlashCommandResult,
ReplyChunk,
ResumableAgent,
SlashCommand,
TerminalSession,
} from "@/domain";
import type {
AgentChatHandle,
AgentGateway,
ChatDraftScope,
ChatDraftSnapshot,
ConversationDetails,
CreateAgentInput,
LiveAgent,
OpenTerminalOptions,
ReattachAgentChatResult,
ReattachResult,
SendAgentChatOptions,
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;
/** Backend-derived routing: `chat` for structured sessions, `pty` for native TUI. */
cellKind?: "chat" | "pty";
/** Conversation id minted by this launch (omitted when nothing was assigned). */
assignedConversationId?: string;
}
function structuredLaunchRoutedToPtyError(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
response: LaunchAgentResponse,
): GatewayError {
const actual = response.cellKind ?? "missing";
return {
code: "STRUCTURED_ROUTED_TO_PTY",
message:
`custom CLI launch for agent ${agentId} in project ${projectId} ` +
`returned cellKind=${actual}; expected chat. ` +
`sessionId=${response.sessionId}; nodeId=${options.nodeId ?? "none"}`,
};
}
export class TauriAgentGateway implements AgentGateway {
listAgents(projectId: string): Promise<Agent[]> {
return invoke<Agent[]>("list_agents", { projectId });
}
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
return invoke<LiveAgent[]>("list_live_agents", { projectId });
}
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
// `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<LiveAgent> {
return invoke<LiveAgent>("attach_live_agent", {
request: { projectId, agentId, nodeId },
});
}
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
return invoke<StoppedLiveAgent>("stop_live_agent", {
request: { projectId, agentId },
});
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level.
return 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 }> {
// `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 } },
);
}
updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
return invoke<Agent>("update_agent_effort", {
request: { projectId, agentId, effort },
});
}
updateAgentContactable(
projectId: string,
agentId: string,
contactable: boolean,
): Promise<Agent> {
return invoke<Agent>("update_agent_contactable", {
request: { projectId, agentId, contactable },
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<AgentContextDocument>("read_agent_context", {
projectId,
agentId,
}).then((res) => res.content);
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
// `update_agent_context` takes a single `request` DTO.
await invoke("update_agent_context", {
request: { projectId, agentId, content },
});
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
await invoke("delete_agent", { projectId, agentId });
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<LaunchAgentResponse>("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,
freshStart: options.freshStart ?? false,
// 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 launchAgentChat(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle> {
// `launch_agent` is the routing authority. For structured/headless profiles
// the backend ignores the PTY output channel and returns a structured session
// id; for non-structured profiles it may still route to PTY, so callers gate
// this method on `profile.structuredAdapter`.
const channel = new Channel<number[]>();
const res = await invoke<LaunchAgentResponse>("launch_agent", {
request: {
projectId,
agentId,
rows: options.rows,
cols: options.cols,
cellKind: "chat",
conversationId: options.conversationId ?? null,
freshStart: options.freshStart ?? false,
nodeId: options.nodeId ?? null,
},
onOutput: channel,
});
if (res.cellKind !== "chat") {
const error = structuredLaunchRoutedToPtyError(projectId, agentId, options, res);
console.error("[ticket149] launchAgentChat:routed-to-non-chat", {
projectId,
agentId,
request: {
rows: options.rows,
cols: options.cols,
cellKind: "chat",
conversationId: options.conversationId ?? null,
nodeId: options.nodeId ?? null,
},
response: res,
error,
});
throw error;
}
return {
sessionId: res.sessionId,
cellKind: "chat",
...(res.assignedConversationId
? { assignedConversationId: res.assignedConversationId }
: {}),
};
}
async reattachAgentChat(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult> {
const channel = new Channel<ReplyChunk>();
channel.onmessage = onChunk;
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
sessionId,
onReply: channel,
});
}
async detachAgentChat(sessionId: string): Promise<void> {
await invoke("detach_agent_chat", { sessionId });
}
async sendAgentChat(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
options: SendAgentChatOptions = {},
): Promise<void> {
const channel = new Channel<ReplyChunk>();
channel.onmessage = onChunk;
await invoke("agent_send", {
sessionId,
prompt,
...(options.attachments ? { attachments: options.attachments } : {}),
onReply: channel,
});
}
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
const request = prefix === undefined ? {} : { prefix };
const res = await invoke<{ commands: SlashCommand[] }>("list_slash_commands", {
request,
});
return res.commands;
}
async executeSlashCommand(
name: string,
options: { sessionId?: string | null; arguments?: unknown[] } = {},
): Promise<ExecuteSlashCommandResult> {
return invoke<ExecuteSlashCommandResult>("execute_slash_command", {
request: {
name,
sessionId: options.sessionId ?? null,
arguments: options.arguments ?? [],
},
});
}
readChatDraft(scope: ChatDraftScope): Promise<ChatDraftSnapshot> {
return invoke<ChatDraftSnapshot>("read_chat_draft", {
request: scope,
});
}
async saveChatDraft(scope: ChatDraftScope, text: string): Promise<void> {
await invoke("save_chat_draft", {
request: { ...scope, text },
});
}
async clearChatDraft(scope: ChatDraftScope): Promise<void> {
await invoke("clear_chat_draft", {
request: scope,
});
}
async closeAgentChat(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}
async cancelAgentChat(sessionId: string): Promise<void> {
await invoke("cancel_agent_chat", { sessionId });
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
// 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<number[]>();
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<ConversationDetails> {
// `inspect_conversation` takes a single `request` DTO; absent fields are
// omitted from the response (best-effort), so an empty object is valid.
return invoke<ConversationDetails>("inspect_conversation", {
request: { projectId, agentId, conversationId },
});
}
}