Files
IdeA/frontend/src/adapters/agent.ts
Blomios 7375f706da feat(agent): reprise des sessions au redémarrage (B2) — commande + ResumeProjectPanel
- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
  { resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
  câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
  de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
  Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
  Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).

Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:41 +02:00

187 lines
6.3 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, ResumableAgent, TerminalSession } from "@/domain";
import type {
AgentGateway,
ConversationDetails,
CreateAgentInput,
LiveAgent,
OpenTerminalOptions,
ReattachResult,
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<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 },
}).then((list) => {
const attached = list[0];
if (!attached) {
throw new Error(`running session for agent ${agentId} was not returned`);
}
return attached;
});
}
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 } },
);
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}
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,
// Hosting cell: lets the backend enforce one-live-session-per-agent.
nodeId: options.nodeId ?? null,
},
onOutput: channel,
});
const handle = makeTerminalHandle(res.sessionId, channel);
// Surface the id assigned by this launch so the caller persists it on the
// leaf (`setCellConversation`) and resumes next time.
return res.assignedConversationId
? { ...handle, assignedConversationId: res.assignedConversationId }
: handle;
}
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 },
});
}
}