feat(agent): UI hot-swap de profil (A2) — commande Tauri + sélecteur + dialog

- Tauri : commande change_agent_profile + ChangeAgentProfileRequestDto/Dto
  (camelCase, relaunchedSession omis si None), câblage state.rs par composition.
- Front : gateway changeAgentProfile (adapters Tauri+mock), sélecteur de profil
  par agent, dialog de confirmation FR (« Changer le moteur abandonne l'historique
  de conversation… »), refresh sur event agentProfileChanged.

Tests : app-tauri dto 5/5 ; vitest 314/314 (mapping, payload, dialog gating,
libellé FR, refresh). 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:07:35 +02:00
parent 2433e173a1
commit b82e3e1a40
13 changed files with 731 additions and 10 deletions

View File

@ -69,4 +69,53 @@ describe("TauriAgentGateway invoke payloads", () => {
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
});
});
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {
invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } });
await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(invoke).toHaveBeenCalledWith("change_agent_profile", {
request: {
projectId: "proj-1",
agentId: "agent-2",
profileId: "prof-9",
rows: 30,
cols: 120,
},
});
});
it("change_agent_profile returns the mutated agent and the relaunched session", async () => {
const response = {
agent: { id: "agent-2", profileId: "prof-9" },
relaunchedSession: { sessionId: "sess-7", cwd: "/p", rows: 30, cols: 120 },
};
invoke.mockResolvedValueOnce(response);
const out = await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(out.agent.profileId).toBe("prof-9");
expect(out.relaunchedSession?.sessionId).toBe("sess-7");
});
it("change_agent_profile leaves relaunchedSession undefined when the backend omits it", async () => {
invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } });
const out = await new TauriAgentGateway().changeAgentProfile(
"proj-1",
"agent-2",
"prof-9",
30,
120,
);
expect(out.relaunchedSession).toBeUndefined();
});
});

View File

@ -14,7 +14,7 @@
import { Channel, invoke } from "@tauri-apps/api/core";
import type { Agent } from "@/domain";
import type { Agent, TerminalSession } from "@/domain";
import type {
AgentGateway,
ConversationDetails,
@ -74,6 +74,23 @@ export class TauriAgentGateway implements AgentGateway {
});
}
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 });
}

View File

@ -32,6 +32,7 @@ import type {
Skill,
SkillScope,
Template,
TerminalSession,
Unsubscribe,
} from "@/domain";
import type {
@ -305,6 +306,61 @@ export class MockAgentGateway implements AgentGateway {
this.contexts.delete(this.contextKey(projectId, agentId));
}
async changeAgentProfile(
projectId: string,
agentId: string,
profileId: string,
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
// Mutate the agent's runtime profile in place (the rest of the record —
// origin, synchronized, skills — is untouched).
list[idx] = { ...list[idx], profileId };
const agent = structuredClone(list[idx]);
// Hot-swap path: when the agent owns a live session, the engine restarts —
// the old PTY is killed (history abandoned, per the product decision) and a
// fresh session is minted in the same cell, surfaced for the caller to
// rebind. No live session ⇒ nothing to relaunch.
const nodeId = this.liveByAgent.get(agentId);
const oldSessionId = this.liveSessionByAgent.get(agentId);
if (!nodeId || !oldSessionId) {
return { agent };
}
// Kill the old session.
const old = this.sessions.get(oldSessionId);
if (old) old.closed = true;
this.sessions.delete(oldSessionId);
// Mint a fresh, detached session (no view sink yet; the cell reattaches).
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const session = new MockPtySession(sessionId, () => {});
session.detach();
this.sessions.set(sessionId, session);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveByAgent.set(agentId, nodeId);
return {
agent,
relaunchedSession: {
sessionId,
cwd: `/home/user/mock-project`,
rows,
cols,
},
};
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**