From b82e3e1a405aeb6eaa02eed4d8508bc9c51383a6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 9 Jun 2026 10:07:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20UI=20hot-swap=20de=20profil=20(A?= =?UTF-8?q?2)=20=E2=80=94=20commande=20Tauri=20+=20s=C3=A9lecteur=20+=20di?= =?UTF-8?q?alog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/app-tauri/src/commands.rs | 44 ++++- crates/app-tauri/src/dto.rs | 56 ++++++- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/state.rs | 20 ++- .../tests/dto_change_agent_profile.rs | 155 ++++++++++++++++++ frontend/src/adapters/agent.test.ts | 49 ++++++ frontend/src/adapters/agent.ts | 19 ++- frontend/src/adapters/mock/index.ts | 56 +++++++ frontend/src/domain/index.ts | 22 +++ frontend/src/features/agents/AgentsPanel.tsx | 93 +++++++++++ frontend/src/features/agents/agents.test.tsx | 151 +++++++++++++++++ frontend/src/features/agents/useAgents.ts | 59 ++++++- frontend/src/ports/index.ts | 16 ++ 13 files changed, 731 insertions(+), 10 deletions(-) create mode 100644 crates/app-tauri/tests/dto_change_agent_profile.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 774a011..d9f6758 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -9,8 +9,9 @@ use tauri::State; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ - AppError, AssignSkillToAgentInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, - CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, + AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, + CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, + DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, @@ -27,7 +28,8 @@ use crate::dto::{ parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, - AttachLiveAgentRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + AttachLiveAgentRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, + ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, @@ -1037,6 +1039,42 @@ pub async fn launch_agent( Ok(TerminalSessionDto::from(output)) } +/// `change_agent_profile` — hot-swap an agent's runtime profile (§15.1). +/// +/// Mutates the profile in the manifest, clears the now-foreign conversation id on +/// every persisted layout cell hosting the agent, and — if the agent is live — +/// kills its PTY and relaunches the session in the same cell with the new engine. +/// Announces [`DomainEvent::AgentProfileChanged`]. +/// +/// Returns the mutated [`AgentDto`] and the relaunched [`TerminalSessionDto`] when +/// a live session was hot-swapped (absent otherwise). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project, agent or target profile is unknown, `STORE`/`FILESYSTEM`/`PROCESS` on +/// the respective port failures). +#[tauri::command] +pub async fn change_agent_profile( + request: ChangeAgentProfileRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let profile_id = parse_profile_id(&request.profile_id)?; + state + .change_agent_profile + .execute(ChangeAgentProfileInput { + project, + agent_id, + profile_id, + rows: request.rows, + cols: request.cols, + }) + .await + .map(ChangeAgentProfileDto::from) + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Templates & sync (L7) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index fb06a98..31af6ee 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1032,10 +1032,10 @@ pub fn parse_profile_id(raw: &str) -> Result { // --------------------------------------------------------------------------- use application::{ - CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, - ReadAgentContextOutput, + ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, + ListAgentsOutput, ReadAgentContextOutput, }; -use domain::Agent; +use domain::{Agent, TerminalSession}; /// An agent crossing the wire. [`Agent`] already serialises camelCase /// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), @@ -1154,6 +1154,56 @@ impl From for TerminalSessionDto { } } +/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime +/// profile, optionally relaunching its live session in place. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose runtime profile to change. + pub agent_id: String, + /// Id of the new runtime profile. + pub profile_id: String, + /// Terminal height in rows for a possible hot relaunch. + pub rows: u16, + /// Terminal width in columns for a possible hot relaunch. + pub cols: u16, +} + +/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly +/// relaunched session when a live session was hot-swapped (absent otherwise). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeAgentProfileDto { + /// The agent now carrying the new profile. + pub agent: AgentDto, + /// The relaunched session, present only when a live session was swapped. + #[serde(skip_serializing_if = "Option::is_none")] + pub relaunched_session: Option, +} + +impl From for ChangeAgentProfileDto { + fn from(out: ChangeAgentProfileOutput) -> Self { + Self { + agent: AgentDto(out.agent), + relaunched_session: out.relaunched.map(TerminalSessionDto::from), + } + } +} + +impl From for TerminalSessionDto { + fn from(s: TerminalSession) -> Self { + Self { + session_id: s.id.to_string(), + cwd: s.cwd.as_str().to_owned(), + rows: s.pty_size.rows, + cols: s.pty_size.cols, + assigned_conversation_id: None, + } + } +} + /// Request DTO for `inspect_conversation` (T7). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index d5c8060..6dfc313 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -126,6 +126,7 @@ pub fn run() { commands::update_agent_context, commands::delete_agent, commands::launch_agent, + commands::change_agent_profile, commands::inspect_conversation, commands::create_template, commands::update_template, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 21e3f50..1a18445 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -10,7 +10,8 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ - AssignSkillToAgent, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, + AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, + CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, @@ -128,6 +129,8 @@ pub struct AppState { pub delete_agent: Arc, /// Launch an agent (spawn PTY, apply injection strategy). pub launch_agent: Arc, + /// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1). + pub change_agent_profile: Arc, /// Best-effort inspection of a conversation (last topic + token indicator) /// for the resume popup (T7). Optional/extensible: backed by a `Vec` of /// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty @@ -514,6 +517,20 @@ impl AppState { Some(Arc::clone(&check_embedder_suggestion)), )); + // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ + // profile/project/fs stores, the live-session registry and PTY port, and + // *composes* the launcher above for the in-place relaunch (no duplication). + let change_agent_profile = Arc::new(ChangeAgentProfile::new( + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&terminal_sessions), + Arc::clone(&pty_port), + Arc::clone(&launch_agent), + Arc::clone(&events_port), + )); + // --- Conversation inspection (T7) --- // Best-effort, optional, extensible: a `Vec` of SessionInspectors routed // by profile. Adding an inspectable CLI = pushing one more adapter here. @@ -699,6 +716,7 @@ impl AppState { update_agent_context, delete_agent, launch_agent, + change_agent_profile, inspect_conversation, project_store, create_template, diff --git a/crates/app-tauri/tests/dto_change_agent_profile.rs b/crates/app-tauri/tests/dto_change_agent_profile.rs new file mode 100644 index 0000000..ad8b182 --- /dev/null +++ b/crates/app-tauri/tests/dto_change_agent_profile.rs @@ -0,0 +1,155 @@ +//! A2 tests for the `change_agent_profile` DTO contract (§15.1): +//! - Request DTO round-trips camelCase JSON `{ projectId, agentId, profileId, rows, cols }`. +//! - `From for ChangeAgentProfileDto` maps the agent and +//! surfaces `relaunchedSession` only when a live session was swapped (omitted via +//! `skip_serializing_if` when `None`). +//! - `From for TerminalSessionDto` carries id/cwd/size. + +use app_tauri_lib::dto::{ChangeAgentProfileDto, ChangeAgentProfileRequestDto}; +use application::ChangeAgentProfileOutput; +use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; +use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; +use domain::{Agent, AgentOrigin, ProjectPath}; +use serde_json::json; +use uuid::Uuid; + +/// Helper: build a minimal validated [`Agent`]. +fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent { + Agent::new( + AgentId::from_uuid(Uuid::from_u128(agent_uuid)), + "My Agent", + "agents/my-agent.md", + ProfileId::from_uuid(Uuid::from_u128(profile_uuid)), + AgentOrigin::Scratch, + false, + ) + .expect("valid agent") +} + +/// Helper: build a running [`TerminalSession`] for an agent cell. +fn make_session(session_uuid: u128, node_uuid: u128, agent_uuid: u128) -> TerminalSession { + let session_id = SessionId::from_uuid(Uuid::from_u128(session_uuid)); + let node_id = NodeId::from_uuid(Uuid::from_u128(node_uuid)); + let agent_id = AgentId::from_uuid(Uuid::from_u128(agent_uuid)); + let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path"); + let size = PtySize::new(30, 100).unwrap(); + let mut session = TerminalSession::starting( + session_id, + node_id, + cwd, + SessionKind::Agent { agent_id }, + size, + ); + session.status = SessionStatus::Running; + session +} + +// --------------------------------------------------------------------------- +// Request DTO deserialisation (camelCase round-trip) +// --------------------------------------------------------------------------- + +#[test] +fn change_agent_profile_request_deserialises_camelcase() { + let project_id = Uuid::from_u128(1).to_string(); + let agent_id = Uuid::from_u128(2).to_string(); + let profile_id = Uuid::from_u128(3).to_string(); + let raw = json!({ + "projectId": project_id, + "agentId": agent_id, + "profileId": profile_id, + "rows": 24, + "cols": 80 + }); + let dto: ChangeAgentProfileRequestDto = serde_json::from_value(raw).unwrap(); + assert_eq!(dto.project_id, project_id); + assert_eq!(dto.agent_id, agent_id); + assert_eq!(dto.profile_id, profile_id); + assert_eq!(dto.rows, 24); + assert_eq!(dto.cols, 80); +} + +#[test] +fn change_agent_profile_request_rejects_snake_case_keys() { + // The wire contract is camelCase; snake_case keys must NOT satisfy the struct. + let raw = json!({ + "project_id": Uuid::from_u128(1).to_string(), + "agent_id": Uuid::from_u128(2).to_string(), + "profile_id": Uuid::from_u128(3).to_string(), + "rows": 24, + "cols": 80 + }); + let res: Result = serde_json::from_value(raw); + assert!(res.is_err(), "snake_case keys must not deserialise"); +} + +// --------------------------------------------------------------------------- +// From for ChangeAgentProfileDto +// --------------------------------------------------------------------------- + +#[test] +fn output_maps_agent_and_omits_session_when_no_relaunch() { + let agent = make_agent(5, 6); + let out = ChangeAgentProfileOutput { + agent: agent.clone(), + relaunched: None, + }; + let dto = ChangeAgentProfileDto::from(out); + assert_eq!(dto.agent.0.id, agent.id); + assert!(dto.relaunched_session.is_none()); + + let v = serde_json::to_value(&dto).unwrap(); + // The agent is embedded with its camelCase shape. + assert_eq!(v["agent"]["id"], agent.id.to_string()); + assert_eq!(v["agent"]["profileId"], agent.profile_id.to_string()); + // No relaunch ⇒ field must be OMITTED from the wire (absent, not null). + assert!( + v.get("relaunchedSession").is_none(), + "no relaunch ⇒ relaunchedSession omitted, got: {v}" + ); + // No snake_case leak. + assert!(v.get("relaunched_session").is_none()); +} + +#[test] +fn output_maps_relaunched_session_camelcase_when_present() { + let agent = make_agent(7, 8); + let session = make_session(11, 12, 7); + let out = ChangeAgentProfileOutput { + agent: agent.clone(), + relaunched: Some(session.clone()), + }; + let dto = ChangeAgentProfileDto::from(out); + assert!(dto.relaunched_session.is_some()); + + let v = serde_json::to_value(&dto).unwrap(); + let rs = v + .get("relaunchedSession") + .expect("relaunch present ⇒ relaunchedSession serialised"); + assert_eq!(rs["sessionId"], session.id.to_string()); + assert_eq!(rs["cwd"], "/tmp/project"); + assert_eq!(rs["rows"], 30); + assert_eq!(rs["cols"], 100); + // No snake_case leak on the nested DTO. + assert!(rs.get("session_id").is_none(), "no snake_case leak"); + assert!(v.get("relaunched_session").is_none()); +} + +// --------------------------------------------------------------------------- +// From for TerminalSessionDto +// --------------------------------------------------------------------------- + +#[test] +fn terminal_session_maps_to_dto() { + let session = make_session(21, 22, 23); + // Exercise the From impl directly through the output mapping. + let out = ChangeAgentProfileOutput { + agent: make_agent(23, 24), + relaunched: Some(session.clone()), + }; + let dto = ChangeAgentProfileDto::from(out); + let rs = dto.relaunched_session.expect("session present"); + assert_eq!(rs.session_id, session.id.to_string()); + assert_eq!(rs.cwd, "/tmp/project"); + assert_eq!(rs.rows, 30); + assert_eq!(rs.cols, 100); +} diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 031b6a0..ae8bac5 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -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(); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 062d24b..5990aa8 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -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 { return invoke("read_agent_context", { projectId, agentId }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index af035a7..8963b7c 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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) ── /** diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8a66ef8..08d7433 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -18,6 +18,7 @@ export type DomainEvent = | { type: "projectCreated"; projectId: string } | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } + | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "templateUpdated"; templateId: string; version: number } | { type: "agentDriftDetected"; agentId: string; from: number; to: number } | { type: "agentSynced"; agentId: string; to: number } @@ -251,6 +252,27 @@ export interface Agent { skills: SkillRef[]; } +/** + * A terminal/PTY session as returned by the backend (mirror of + * `TerminalSessionDto`, camelCase wire format). Surfaced by `changeAgentProfile` + * as the freshly relaunched session when a live agent was hot-swapped. + */ +export interface TerminalSession { + /** Stable session id (UUID) — used for write/resize/close + the output channel. */ + sessionId: string; + /** Working directory the shell runs in. */ + cwd: string; + /** Current rows. */ + rows: number; + /** Current cols. */ + cols: number; + /** + * Conversation id assigned by this (re)launch, when the profile supports + * session assignment and the hosting cell had none yet; absent otherwise. + */ + assignedConversationId?: string; +} + // --------------------------------------------------------------------------- // Skills (L12) — mirror of the domain `Skill` / `SkillRef`. // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 1069a20..def02a0 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -127,6 +127,31 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {}, ); + /** + * Pending profile change awaiting confirmation: the target agent + the chosen + * profile id. `null` when no dialog is open. Switching the engine abandons the + * conversation history, so it is gated behind an explicit confirmation. + */ + const [pendingProfileChange, setPendingProfileChange] = useState<{ + agentId: string; + profileId: string; + } | null>(null); + + async function confirmProfileChange() { + if (!pendingProfileChange) return; + const { agentId, profileId } = pendingProfileChange; + setPendingProfileChange(null); + // Use the running agent terminal's size when available; fall back to a sane + // default (the backend only needs these for a possible hot relaunch). + const relaunched = await vm.changeAgentProfile(agentId, profileId, 24, 80); + // On a hot relaunch the backend mints a fresh session; rebind the panel's + // terminal to it and drop any persisted conversation id for this cell (the + // history is gone — the front reflects the swap for the current session). + if (relaunched && agentId === activeAgentId) { + setAgentSessions((prev) => ({ ...prev, [agentId]: relaunched.sessionId })); + } + } + const canCreate = newName.trim().length > 0 && !vm.busy; async function handleCreate(e: React.FormEvent) { @@ -332,6 +357,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
+ {/* Profile hot-swap selector (Chantier A). Changing the + engine abandons the conversation history → confirmation. */} + {vm.profiles.length > 0 && ( + + )} {agentDrift && (
)} + + {/* ── Profile-change confirmation dialog ── */} + {pendingProfileChange && ( +
+
+

+ Changer le moteur IA +

+

+ Changer le moteur abandonne l'historique de conversation (le + contexte et la mémoire sont conservés). Continuer ? +

+
+ + +
+
+
+ )} ); } diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index dfc1ee3..a02c28c 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -390,6 +390,125 @@ describe("MockAgentGateway (unit)", () => { }); }); +// --------------------------------------------------------------------------- +// A2 — profile hot-swap selector + confirmation dialog +// --------------------------------------------------------------------------- + +/** Seeds two profiles so an agent's engine can be swapped to a different one. */ +async function seededProfiles(): Promise { + const profile = new MockProfileGateway(); + await profile.configureProfiles([ + { + id: "prof-1", + name: "Claude Code", + command: "claude", + args: [], + contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + }, + { + id: "prof-2", + name: "Codex CLI", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + }, + ]); + return profile; +} + +describe("AgentsPanel profile hot-swap (A2)", () => { + it("changing the profile select does NOT call changeAgentProfile immediately — it opens a confirmation dialog", async () => { + const agent = new MockAgentGateway(); + await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); + const swapSpy = vi.spyOn(agent, "changeAgentProfile"); + + renderPanel(agent, await seededProfiles()); + await waitForIdle(); + await screen.findByText("Swap"); + + const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "prof-2" } }); + + // No gateway call yet — only the dialog appears. + expect(swapSpy).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("Cancel closes the dialog without calling changeAgentProfile", async () => { + const agent = new MockAgentGateway(); + await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); + const swapSpy = vi.spyOn(agent, "changeAgentProfile"); + + renderPanel(agent, await seededProfiles()); + await waitForIdle(); + await screen.findByText("Swap"); + + fireEvent.change(screen.getByLabelText("profile for Swap"), { + target: { value: "prof-2" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "cancel profile change" }), + ); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(swapSpy).not.toHaveBeenCalled(); + }); + + it("Continue confirms and calls changeAgentProfile with the chosen profile", async () => { + const agent = new MockAgentGateway(); + const created = await agent.createAgent(PROJECT_ID, { + name: "Swap", + profileId: "prof-1", + }); + const swapSpy = vi.spyOn(agent, "changeAgentProfile"); + + renderPanel(agent, await seededProfiles()); + await waitForIdle(); + await screen.findByText("Swap"); + + fireEvent.change(screen.getByLabelText("profile for Swap"), { + target: { value: "prof-2" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "confirm profile change" }), + ); + + await waitFor(() => { + expect(swapSpy).toHaveBeenCalled(); + expect(swapSpy.mock.calls[0][0]).toBe(PROJECT_ID); + expect(swapSpy.mock.calls[0][1]).toBe(created.id); + expect(swapSpy.mock.calls[0][2]).toBe("prof-2"); + }); + // Dialog closes after confirmation. + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("the confirmation dialog message is in FRENCH and conveys the spec §15.1 wording", async () => { + const agent = new MockAgentGateway(); + await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); + + renderPanel(agent, await seededProfiles()); + await waitForIdle(); + await screen.findByText("Swap"); + + fireEvent.change(screen.getByLabelText("profile for Swap"), { + target: { value: "prof-2" }, + }); + + const dialog = screen.getByRole("dialog"); + const text = dialog.textContent ?? ""; + // Spec §15.1: « Changer le moteur abandonne l'historique de conversation + // (le contexte et la mémoire sont conservés). Continuer ? » — must be FRENCH. + expect(text).toMatch(/abandonne l'historique de conversation/i); + expect(text).toMatch(/contexte et la mémoire sont conservés/i); + expect(text).toMatch(/Continuer\s*\?/); + }); +}); + describe("AgentsPanel live refresh on domain events", () => { it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => { const agent = new MockAgentGateway(); @@ -423,6 +542,38 @@ describe("AgentsPanel live refresh on domain events", () => { expect(await screen.findByText("Orchestrated")).toBeTruthy(); }); + it("refreshes the list when an `agentProfileChanged` event fires (A2 hot-swap)", async () => { + const agent = new MockAgentGateway(); + const profile = new MockProfileGateway(); + const system = new MockSystemGateway(); + const template = new MockTemplateGateway(agent); + const gateways = { agent, profile, system, template } as unknown as Gateways; + + const created = await agent.createAgent(PROJECT_ID, { + name: "Swapper", + profileId: "p1", + }); + + render( + + + , + ); + await waitFor(() => expect(screen.getByText("Swapper")).toBeTruthy()); + + // Mutate the profile out of band, then emit the event the backend relays. + await agent.changeAgentProfile(PROJECT_ID, created.id, "p2", 24, 80); + const refreshSpy = vi.spyOn(agent, "listAgents"); + system.emit({ + type: "agentProfileChanged", + agentId: created.id, + profileId: "p2", + }); + + // The hook re-fetches the list on the event. + await waitFor(() => expect(refreshSpy).toHaveBeenCalled()); + }); + it("does not crash when the system gateway is absent", async () => { // The existing renderPanel helper injects no `system` gateway; the // subscription effect must no-op rather than throw. diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index b25e669..dce929c 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -9,7 +9,12 @@ import { useCallback, useEffect, useState } from "react"; -import type { Agent, AgentProfile, GatewayError } from "@/domain"; +import type { + Agent, + AgentProfile, + GatewayError, + TerminalSession, +} from "@/domain"; import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports"; import { useGateways } from "@/app/di"; @@ -44,6 +49,19 @@ export interface AgentsViewModel { selectAgent: (agentId: string) => Promise; /** Saves the context for the currently selected agent. */ saveContext: (content: string) => Promise; + /** + * Hot-swaps an agent's runtime AI profile. The caller is expected to have + * confirmed with the user first (the conversation history is abandoned). On a + * live agent the backend relaunches the session and returns it so the cell can + * rebind; the agent list is refreshed locally. Returns the relaunched session, + * if any, so the panel can rebind the hosting cell. + */ + changeAgentProfile: ( + agentId: string, + profileId: string, + rows: number, + cols: number, + ) => Promise; /** Deletes an agent; deselects if it was selected. */ deleteAgent: (agentId: string) => Promise; /** @@ -124,7 +142,11 @@ export function useAgents(projectId: string): AgentsViewModel { let cancelled = false; void system .onDomainEvent((event) => { - if (event.type === "agentLaunched" || event.type === "agentExited") { + if ( + event.type === "agentLaunched" || + event.type === "agentExited" || + event.type === "agentProfileChanged" + ) { void refresh(); void refreshLiveAgents(); } @@ -193,6 +215,38 @@ export function useAgents(projectId: string): AgentsViewModel { [agent, projectId, selectedAgentId], ); + const changeAgentProfile = useCallback( + async ( + agentId: string, + profileId: string, + rows: number, + cols: number, + ): Promise => { + setBusy(true); + setError(null); + try { + const { agent: updated, relaunchedSession } = + await agent.changeAgentProfile(projectId, agentId, profileId, rows, cols); + // Reflect the new profile locally; the `agentProfileChanged` event (when + // wired) also triggers a full refresh, but updating here keeps the UI + // responsive offline / in tests. + setAgents((prev) => + prev.map((a) => (a.id === updated.id ? updated : a)), + ); + // A hot relaunch produces a fresh live session id — keep the live list + // in sync so the cell can rebind. + void refreshLiveAgents(); + return relaunchedSession; + } catch (e) { + setError(describe(e)); + return undefined; + } finally { + setBusy(false); + } + }, + [agent, projectId, refreshLiveAgents], + ); + const deleteAgent = useCallback( async (agentId: string) => { setBusy(true); @@ -271,6 +325,7 @@ export function useAgents(projectId: string): AgentsViewModel { createAgent, selectAgent, saveContext, + changeAgentProfile, deleteAgent, launchAgent, stopAgent, diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 4d13ffb..2308a9c 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -34,6 +34,7 @@ import type { Skill, SkillScope, Template, + TerminalSession, Unsubscribe, } from "@/domain"; @@ -96,6 +97,21 @@ export interface AgentGateway { ): Promise; /** Creates a new agent from scratch; returns the created agent. */ createAgent(projectId: string, input: CreateAgentInput): Promise; + /** + * Hot-swaps an agent's runtime AI profile (Chantier A). The conversation + * history is abandoned (the product decision is "start fresh"); the agent's + * context `.md` and project memory are preserved. When the agent has a live + * session it is relaunched on the new profile and returned as + * {@link TerminalSession} so the hosting cell can rebind; otherwise + * `relaunchedSession` is absent. + */ + changeAgentProfile( + projectId: string, + agentId: string, + profileId: string, + rows: number, + cols: number, + ): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }>; /** Reads an agent's `.md` context by agent id. */ readContext(projectId: string, agentId: string): Promise; /** Overwrites an agent's `.md` context. */