From 7375f706daf4fff2b837f6b86afe155e7f1a2ea1 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 9 Jun 2026 13:04:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20reprise=20des=20sessions=20au=20?= =?UTF-8?q?red=C3=A9marrage=20(B2)=20=E2=80=94=20commande=20+=20ResumeProj?= =?UTF-8?q?ectPanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARCHITECTURE.md | 2 +- crates/app-tauri/src/commands.rs | 31 +- crates/app-tauri/src/dto.rs | 66 ++++ crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/state.rs | 16 +- .../app-tauri/tests/dto_resumable_agents.rs | 192 ++++++++++++ frontend/src/adapters/agent.ts | 11 +- frontend/src/adapters/mock/index.ts | 16 + frontend/src/domain/index.ts | 19 ++ .../features/agents/ResumeProjectPanel.tsx | 154 ++++++++++ frontend/src/features/agents/index.ts | 4 + .../features/agents/resumeProject.test.tsx | 283 ++++++++++++++++++ .../src/features/agents/useResumeProject.ts | 181 +++++++++++ .../src/features/projects/ProjectsView.tsx | 13 +- frontend/src/ports/index.ts | 9 + 15 files changed, 992 insertions(+), 6 deletions(-) create mode 100644 crates/app-tauri/tests/dto_resumable_agents.rs create mode 100644 frontend/src/features/agents/ResumeProjectPanel.tsx create mode 100644 frontend/src/features/agents/resumeProject.test.tsx create mode 100644 frontend/src/features/agents/useResumeProject.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6f3c546..0beba57 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1160,7 +1160,7 @@ La **reprise effective** d'un agent choisi dans le panneau **ne crée aucun nouv |-------------------------|-------------------------|----------------------| | list_resumable_agents | { projectId } | ResumableAgentListDto| ``` -`ResumableAgentListDto { agents: Vec }`, `ResumableAgentDto { agentId, name, nodeId, conversationId?, wasRunning, resumeSupported }`. Lecture seule, pas d'event. Enregistrer dans `generate_handler!`, câbler `ListResumableAgents` dans `state.rs` (réutilise stores + `ProfileStore` déjà injectés). +`ResumableAgentListDto { resumable: Vec }`, `ResumableAgentDto { agentId, name, nodeId, conversationId?, wasRunning, resumeSupported }`. Lecture seule, pas d'event. Enregistrer dans `generate_handler!`, câbler `ListResumableAgents` dans `state.rs` (réutilise stores + `ProfileStore` déjà injectés). #### Frontend - **Port UI** `AgentGateway.listResumableAgents(projectId): Promise` (+ adapter Tauri + mock). diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d9f6758..0b976ad 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -16,7 +16,8 @@ use application::{ DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, - ListMemoriesInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput, + ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, + OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, @@ -40,7 +41,7 @@ use crate::dto::{ LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto, - RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, + RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, @@ -1075,6 +1076,32 @@ pub async fn change_agent_profile( .map_err(ErrorDto::from) } +/// `list_resumable_agents` — read-only inventory of an open project's resumable +/// agent cells (§15.2). Each entry carries the agent + its host cell, the CLI +/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag +/// frozen at close, and whether the profile can resume a conversation. +/// +/// Best-effort by contract: an unreadable project/layout/manifest degrades to an +/// empty list, never an error. Drives the reopen panel, which reuses +/// `launch_agent` for the actual resume (no new resume command). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on a project-registry failure). +#[tauri::command] +pub async fn list_resumable_agents( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .list_resumable_agents + .execute(ListResumableAgentsInput { project }) + .await + .map(ResumableAgentListDto::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 31af6ee..b8d3f43 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1305,6 +1305,72 @@ pub fn parse_agent_id(raw: &str) -> Result { }) } +// --------------------------------------------------------------------------- +// Resumable agents (§15.2 — Chantier B2) +// --------------------------------------------------------------------------- + +use application::{ListResumableAgentsOutput, ResumableAgent}; + +/// One resumable agent cell, as seen by the frontend (§15.2). +/// +/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI +/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag +/// frozen at close, and whether the agent's profile can resume a CLI +/// conversation. `conversationId` is **omitted from the wire when `None`** +/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not +/// `null`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentDto { + /// The resumable agent's id (UUID string). + pub agent_id: String, + /// The agent's display name (resolved from the manifest). + pub name: String, + /// The host layout leaf where the agent is relaunched/resumed (UUID string). + pub node_id: String, + /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh + /// relaunch (no history to resume). + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + /// The `agent_was_running` flag frozen at the cell's close. + pub was_running: bool, + /// `true` when the agent's profile carries a usable resume strategy. + pub resume_supported: bool, +} + +impl From for ResumableAgentDto { + fn from(r: ResumableAgent) -> Self { + Self { + agent_id: r.agent_id.to_string(), + name: r.name, + node_id: r.node_id.to_string(), + conversation_id: r.conversation_id, + was_running: r.was_running, + resume_supported: r.resume_supported, + } + } +} + +/// Response DTO for `list_resumable_agents` (§15.2). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumableAgentListDto { + /// The resumable agent cells, in layout-traversal order. + pub resumable: Vec, +} + +impl From for ResumableAgentListDto { + fn from(out: ListResumableAgentsOutput) -> Self { + Self { + resumable: out + .resumable + .into_iter() + .map(ResumableAgentDto::from) + .collect(), + } + } +} + // --------------------------------------------------------------------------- // Templates & sync (L7) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 6dfc313..6983229 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -127,6 +127,7 @@ pub fn run() { commands::delete_agent, commands::launch_agent, commands::change_agent_profile, + commands::list_resumable_agents, 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 1a18445..6e2ab8b 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -18,7 +18,8 @@ use application::{ DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles, - ListLayouts, ListMemories, ListProfiles, ListProjects, ListSkills, ListTemplates, + ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, + ListTemplates, LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks, @@ -131,6 +132,9 @@ pub struct AppState { pub launch_agent: Arc, /// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1). pub change_agent_profile: Arc, + /// Read-only inventory of a project's resumable agent cells, for the reopen + /// panel (§15.2). + pub list_resumable_agents: 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 @@ -531,6 +535,15 @@ impl AppState { Arc::clone(&events_port), )); + // Read-only inventory of resumable agent cells (§15.2). Reuses the shared + // project/fs/context/profile stores already injected above — no new port. + let list_resumable_agents = Arc::new(ListResumableAgents::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&contexts_port), + Arc::clone(&profile_store_port), + )); + // --- Conversation inspection (T7) --- // Best-effort, optional, extensible: a `Vec` of SessionInspectors routed // by profile. Adding an inspectable CLI = pushing one more adapter here. @@ -717,6 +730,7 @@ impl AppState { delete_agent, launch_agent, change_agent_profile, + list_resumable_agents, inspect_conversation, project_store, create_template, diff --git a/crates/app-tauri/tests/dto_resumable_agents.rs b/crates/app-tauri/tests/dto_resumable_agents.rs new file mode 100644 index 0000000..946159d --- /dev/null +++ b/crates/app-tauri/tests/dto_resumable_agents.rs @@ -0,0 +1,192 @@ +//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2): +//! - `From for ResumableAgentDto` maps every field, surfaces +//! `conversationId` only when present (omitted via `skip_serializing_if` when +//! `None`), and serialises ids as strings + camelCase keys. +//! - `From for ResumableAgentListDto` preserves the +//! `resumable` list and its order. +//! - Full + minimal `ResumableAgentDto` serde round-trips on the wire shape. + +use app_tauri_lib::dto::{ResumableAgentDto, ResumableAgentListDto}; +use application::{ListResumableAgentsOutput, ResumableAgent}; +use domain::ids::{AgentId, NodeId}; +use uuid::Uuid; + +/// Helper: a resumable agent with a conversation id (resume-capable). +fn full(agent_uuid: u128, node_uuid: u128) -> ResumableAgent { + ResumableAgent { + agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)), + name: "Architect".to_owned(), + node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)), + conversation_id: Some("conv-123".to_owned()), + was_running: true, + resume_supported: true, + } +} + +/// Helper: a minimal resumable agent without a conversation id. +fn minimal(agent_uuid: u128, node_uuid: u128) -> ResumableAgent { + ResumableAgent { + agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)), + name: "Scratch".to_owned(), + node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)), + conversation_id: None, + was_running: false, + resume_supported: false, + } +} + +// --------------------------------------------------------------------------- +// From for ResumableAgentDto — field mapping +// --------------------------------------------------------------------------- + +#[test] +fn from_resumable_agent_maps_every_field() { + let r = full(1, 2); + let dto = ResumableAgentDto::from(r.clone()); + + assert_eq!(dto.agent_id, r.agent_id.to_string()); + assert_eq!(dto.name, "Architect"); + assert_eq!(dto.node_id, r.node_id.to_string()); + assert_eq!(dto.conversation_id.as_deref(), Some("conv-123")); + assert!(dto.was_running); + assert!(dto.resume_supported); +} + +#[test] +fn from_resumable_agent_ids_are_strings() { + let r = full(7, 8); + let dto = ResumableAgentDto::from(r.clone()); + // The ids cross the wire as UUID strings (not raw bytes / numbers). + assert_eq!(dto.agent_id, Uuid::from_u128(7).to_string()); + assert_eq!(dto.node_id, Uuid::from_u128(8).to_string()); +} + +// --------------------------------------------------------------------------- +// camelCase wire shape + conversationId omission +// --------------------------------------------------------------------------- + +#[test] +fn full_dto_serialises_camelcase_with_conversation() { + let dto = ResumableAgentDto::from(full(1, 2)); + let v = serde_json::to_value(&dto).unwrap(); + + assert_eq!(v["agentId"], Uuid::from_u128(1).to_string()); + assert_eq!(v["name"], "Architect"); + assert_eq!(v["nodeId"], Uuid::from_u128(2).to_string()); + assert_eq!(v["conversationId"], "conv-123"); + assert_eq!(v["wasRunning"], true); + assert_eq!(v["resumeSupported"], true); + + // No snake_case leak. + assert!(v.get("agent_id").is_none(), "no snake_case leak: {v}"); + assert!(v.get("node_id").is_none()); + assert!(v.get("conversation_id").is_none()); + assert!(v.get("was_running").is_none()); + assert!(v.get("resume_supported").is_none()); +} + +#[test] +fn minimal_dto_omits_conversation_id_when_none() { + let dto = ResumableAgentDto::from(minimal(3, 4)); + let v = serde_json::to_value(&dto).unwrap(); + + // conversationId must be OMITTED (absent, not null) when None. + assert!( + v.get("conversationId").is_none(), + "None conversation ⇒ conversationId omitted, got: {v}" + ); + // Required flags are still present. + assert_eq!(v["wasRunning"], false); + assert_eq!(v["resumeSupported"], false); + assert_eq!(v["name"], "Scratch"); +} + +// --------------------------------------------------------------------------- +// Round-trip serde (Serialize → Deserialize) on the camelCase shape +// --------------------------------------------------------------------------- + +/// A `Deserialize` twin matching the wire shape, used to prove the serialised +/// JSON is exactly the camelCase contract the frontend `ResumableAgent` mirror +/// consumes (`ResumableAgentDto` is serialise-only on the prod side). +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WireResumableAgent { + agent_id: String, + name: String, + node_id: String, + #[serde(default)] + conversation_id: Option, + was_running: bool, + resume_supported: bool, +} + +#[test] +fn full_dto_round_trips_camelcase() { + let dto = ResumableAgentDto::from(full(1, 2)); + let json = serde_json::to_string(&dto).unwrap(); + let back: WireResumableAgent = serde_json::from_str(&json).unwrap(); + + assert_eq!(back.agent_id, Uuid::from_u128(1).to_string()); + assert_eq!(back.name, "Architect"); + assert_eq!(back.node_id, Uuid::from_u128(2).to_string()); + assert_eq!(back.conversation_id.as_deref(), Some("conv-123")); + assert!(back.was_running); + assert!(back.resume_supported); +} + +#[test] +fn minimal_dto_round_trips_camelcase_without_conversation() { + let dto = ResumableAgentDto::from(minimal(3, 4)); + let json = serde_json::to_string(&dto).unwrap(); + // The key is absent from the JSON entirely. + assert!( + !json.contains("conversationId"), + "minimal ⇒ no conversationId key, got: {json}" + ); + let back: WireResumableAgent = serde_json::from_str(&json).unwrap(); + assert_eq!(back.conversation_id, None); + assert!(!back.was_running); + assert!(!back.resume_supported); +} + +// --------------------------------------------------------------------------- +// From for ResumableAgentListDto — list + order +// --------------------------------------------------------------------------- + +#[test] +fn from_output_preserves_list_and_order() { + let out = ListResumableAgentsOutput { + resumable: vec![full(10, 11), minimal(20, 21), full(30, 31)], + }; + let dto = ResumableAgentListDto::from(out); + + assert_eq!(dto.resumable.len(), 3); + // Order preserved. + assert_eq!(dto.resumable[0].agent_id, Uuid::from_u128(10).to_string()); + assert_eq!(dto.resumable[1].agent_id, Uuid::from_u128(20).to_string()); + assert_eq!(dto.resumable[2].agent_id, Uuid::from_u128(30).to_string()); + // Middle entry is the minimal one (no conversation). + assert!(dto.resumable[1].conversation_id.is_none()); +} + +#[test] +fn empty_output_serialises_empty_resumable_array() { + let dto = ResumableAgentListDto::from(ListResumableAgentsOutput::default()); + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["resumable"], serde_json::json!([])); +} + +#[test] +fn list_dto_serialises_camelcase_wrapper() { + let out = ListResumableAgentsOutput { + resumable: vec![full(10, 11)], + }; + let dto = ResumableAgentListDto::from(out); + let v = serde_json::to_value(&dto).unwrap(); + + // Wrapper key is `resumable`; nested entries are camelCase. + let arr = v["resumable"].as_array().expect("resumable is an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["agentId"], Uuid::from_u128(10).to_string()); + assert_eq!(arr[0]["nodeId"], Uuid::from_u128(11).to_string()); +} diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 5990aa8..103387e 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, TerminalSession } from "@/domain"; +import type { Agent, ResumableAgent, TerminalSession } from "@/domain"; import type { AgentGateway, ConversationDetails, @@ -45,6 +45,15 @@ export class TauriAgentGateway implements AgentGateway { 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, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 8963b7c..1a5cd29 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -29,6 +29,7 @@ import type { MemoryType, Project, ProfileAvailability, + ResumableAgent, Skill, SkillScope, Template, @@ -211,6 +212,21 @@ export class MockAgentGateway implements AgentGateway { })); } + /** + * Resumable agents per project, seeded by tests via + * {@link _setResumableAgents}. Empty by default (no panel on open). + */ + private resumable = new Map(); + + /** Seeds the resumable inventory returned for a project (deterministic tests). */ + _setResumableAgents(projectId: string, list: ResumableAgent[]): void { + this.resumable.set(projectId, list); + } + + async listResumableAgents(projectId: string): Promise { + return structuredClone(this.resumable.get(projectId) ?? []); + } + async attachLiveAgent( projectId: string, agentId: string, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 08d7433..6099d9f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -273,6 +273,25 @@ export interface TerminalSession { assignedConversationId?: string; } +/** + * An agent that can be resumed when its project is (re)opened (mirror of the + * backend `ResumableAgentDto`, camelCase wire format; ARCHITECTURE §15.2). It is + * a read-only inventory entry computed at open time — never a spawn: + * - `nodeId` is the layout leaf hosting the agent (where to relaunch), + * - `conversationId` is the persisted CLI conversation id; absent ⇒ relaunch fresh, + * - `wasRunning` is `agentWasRunning` frozen at close time (drives the status label), + * - `resumeSupported` reflects whether the agent's profile exposes a usable + * session strategy; when `false`, "Reprendre" relaunches fresh ("relance à neuf"). + */ +export interface ResumableAgent { + agentId: string; + name: string; + nodeId: string; + conversationId?: string; + wasRunning: boolean; + resumeSupported: boolean; +} + // --------------------------------------------------------------------------- // Skills (L12) — mirror of the domain `Skill` / `SkillRef`. // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/ResumeProjectPanel.tsx b/frontend/src/features/agents/ResumeProjectPanel.tsx new file mode 100644 index 0000000..6195e00 --- /dev/null +++ b/frontend/src/features/agents/ResumeProjectPanel.tsx @@ -0,0 +1,154 @@ +/** + * `ResumeProjectPanel` — the reopen resume panel (L15 / lot B2, ARCHITECTURE + * §15.2). + * + * Mounted **once when a project is (re)opened**, only when the resumable + * inventory is non-empty. It lists every agent cell that "was running" and/or + * carries a persisted conversation id, and lets the user decide, per agent: + * **Reprendre** / **Nouvelle conversation** / **Ignorer**, plus the global + * **Tout reprendre** / **Tout ignorer**. Each choice reuses the existing + * `launch_agent` flow through {@link useResumeProject}; the panel itself is pure + * presentation (it only consumes the view-model, never a gateway directly). + * + * Status / labels mirror {@link ResumeConversationPopup}: the "en cours" / "clôt" + * status is derived **only** from `wasRunning`; an agent whose profile cannot + * resume a conversation (`resumeSupported === false`) shows "relance à neuf". + * + * Empty pending set ⇒ the component renders nothing (no overlay). + */ + +import { Button, cn } from "@/shared"; +import { useResumeProject } from "./useResumeProject"; + +export interface ResumeProjectPanelProps { + /** Project being (re)opened. */ + projectId: string; + /** Working directory the resumed agents launch in (the project root). */ + cwd: string; +} + +export function ResumeProjectPanel({ projectId, cwd }: ResumeProjectPanelProps) { + const vm = useResumeProject(projectId, cwd); + + // No resumable agents ⇒ no panel at all. + if (vm.pending.length === 0) return null; + + return ( +
+
+

+ Reprendre les agents ? +

+

+ Ces agents tournaient à la dernière fermeture du projet. Choisissez + ceux à reprendre. +

+ + {vm.error && ( +

+ {vm.error} +

+ )} + + {/* ── Per-agent rows ── */} +
    + {vm.pending.map((a) => { + const statusLabel = a.wasRunning ? "en cours" : "clôt"; + return ( +
  • +
    + + {a.name} + + + {statusLabel} + +
    + + {!a.resumeSupported && ( +

    + Historique non disponible pour ce moteur — relance à neuf. +

    + )} + +
    + + + +
    +
  • + ); + })} +
+ + {/* ── Global actions ── */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/agents/index.ts b/frontend/src/features/agents/index.ts index 3898a0f..35d7a69 100644 --- a/frontend/src/features/agents/index.ts +++ b/frontend/src/features/agents/index.ts @@ -6,3 +6,7 @@ export { AgentsPanel } from "./AgentsPanel"; export type { AgentsPanelProps } from "./AgentsPanel"; export { useAgents } from "./useAgents"; export type { AgentsViewModel } from "./useAgents"; +export { ResumeProjectPanel } from "./ResumeProjectPanel"; +export type { ResumeProjectPanelProps } from "./ResumeProjectPanel"; +export { useResumeProject } from "./useResumeProject"; +export type { ResumeProjectViewModel } from "./useResumeProject"; diff --git a/frontend/src/features/agents/resumeProject.test.tsx b/frontend/src/features/agents/resumeProject.test.tsx new file mode 100644 index 0000000..a211c8e --- /dev/null +++ b/frontend/src/features/agents/resumeProject.test.tsx @@ -0,0 +1,283 @@ +/** + * B2 — reopen resume flow (ARCHITECTURE §15.2) wired to the stateful + * `MockAgentGateway` / `MockLayoutGateway` via the real `DIProvider`. + * + * Covers both layers visible from the frontend: + * - **Adapter** (`TauriAgentGateway.listResumableAgents`): invokes + * `list_resumable_agents` with a top-level `{ projectId }` payload (NOT + * `{ request: … }`) and unwraps `{ resumable }` → `ResumableAgent[]`. + * - **Panel** (`ResumeProjectPanel`): renders nothing on an empty inventory; + * lists the resumable agents (with their status) when non-empty. + * - **Actions** (`useResumeProject`): Reprendre (with / without resume support), + * Nouvelle conversation, Ignorer, Tout reprendre / Tout ignorer — each + * asserting the exact `launchAgent` / `setCellConversation` calls. + * - French labels on the main title + actions. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; + +import { + MockAgentGateway, + MockLayoutGateway, +} from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import type { ResumableAgent } from "@/domain"; +import { DIProvider } from "@/app/di"; +import { ResumeProjectPanel } from "./ResumeProjectPanel"; + +// --------------------------------------------------------------------------- +// Adapter: TauriAgentGateway.listResumableAgents payload + unwrap +// --------------------------------------------------------------------------- + +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), + Channel: class {}, +})); + +describe("TauriAgentGateway.listResumableAgents (adapter)", () => { + beforeEach(() => invokeMock.mockReset()); + + it("invokes list_resumable_agents with top-level projectId and unwraps { resumable }", async () => { + const { TauriAgentGateway } = await import("@/adapters/agent"); + const wire: ResumableAgent[] = [ + { + agentId: "a1", + name: "Architect", + nodeId: "n1", + conversationId: "c1", + wasRunning: true, + resumeSupported: true, + }, + ]; + invokeMock.mockResolvedValueOnce({ resumable: wire }); + + const gw = new TauriAgentGateway(); + const out = await gw.listResumableAgents("proj-42"); + + // Command name + EXACT payload: top-level projectId, not wrapped in request. + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("list_resumable_agents", { + projectId: "proj-42", + }); + const [, payload] = invokeMock.mock.calls[0]; + expect(payload).not.toHaveProperty("request"); + // Unwrapped to the bare array. + expect(out).toEqual(wire); + }); +}); + +// --------------------------------------------------------------------------- +// Panel + actions: behind the real DIProvider with stateful mocks +// --------------------------------------------------------------------------- + +const PROJECT_ID = "proj-resume-001"; +const CWD = "/home/me/proj"; + +function entry(over: Partial = {}): ResumableAgent { + return { + agentId: "agent-1", + name: "Architect", + nodeId: "node-1", + conversationId: "conv-1", + wasRunning: true, + resumeSupported: true, + ...over, + }; +} + +function renderPanel(seed: ResumableAgent[]) { + const agent = new MockAgentGateway(); + const layout = new MockLayoutGateway(); + agent._setResumableAgents(PROJECT_ID, seed); + + // Spy on the launch/mutate flow so we assert the exact calls without relying + // on the singleton/agent-existence checks of the stateful mock. + const launchSpy = vi + .spyOn(agent, "launchAgent") + .mockResolvedValue({} as never); + // Resolve the layout mutation: the seeded `nodeId`s are synthetic and don't + // exist in the mock's default tree, so the real `applyOperation` would throw. + const mutateSpy = vi + .spyOn(layout, "mutateLayout") + .mockResolvedValue({} as never); + + const gateways = { agent, layout } as unknown as Gateways; + const utils = render( + + + , + ); + return { agent, layout, launchSpy, mutateSpy, ...utils }; +} + +describe("ResumeProjectPanel (panel mounting)", () => { + it("renders nothing when the resumable inventory is empty", async () => { + const { container } = renderPanel([]); + // No async inventory ⇒ stays null. Give the effect a tick to settle. + await waitFor(() => { + expect(screen.queryByTestId("resume-project-panel")).toBeNull(); + }); + expect(container.firstChild).toBeNull(); + }); + + it("mounts the panel listing the resumable agents when non-empty", async () => { + renderPanel([ + entry({ agentId: "a-run", name: "Architect", wasRunning: true }), + entry({ + agentId: "a-closed", + name: "Tester", + nodeId: "node-2", + wasRunning: false, + }), + ]); + + await screen.findByTestId("resume-project-panel"); + expect(screen.getByTestId("resume-row-a-run")).toBeTruthy(); + expect(screen.getByTestId("resume-row-a-closed")).toBeTruthy(); + // Status derived from wasRunning. + expect(screen.getByTestId("resume-status-a-run").textContent).toBe( + "en cours", + ); + expect(screen.getByTestId("resume-status-a-closed").textContent).toBe( + "clôt", + ); + }); + + it("uses French labels for the title and global actions", async () => { + renderPanel([entry()]); + await screen.findByTestId("resume-project-panel"); + // Title carries a non-breaking space before "?" (` `). + expect(screen.getByText(/Reprendre les agents\s*\?/)).toBeTruthy(); + expect(screen.getByText("Tout reprendre")).toBeTruthy(); + expect(screen.getByText("Tout ignorer")).toBeTruthy(); + expect(screen.getByText("Nouvelle conversation")).toBeTruthy(); + }); +}); + +describe("ResumeProjectPanel — per-agent actions", () => { + it("Reprendre (resumeSupported + conversationId) → launchAgent with nodeId + conversationId", async () => { + const { launchSpy } = renderPanel([ + entry({ + agentId: "a1", + nodeId: "node-9", + conversationId: "conv-9", + resumeSupported: true, + }), + ]); + await screen.findByTestId("resume-project-panel"); + + fireEvent.click(screen.getByLabelText("resume Architect")); + + await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); + const [projectId, agentId, options] = launchSpy.mock.calls[0]; + expect(projectId).toBe(PROJECT_ID); + expect(agentId).toBe("a1"); + expect(options.nodeId).toBe("node-9"); + expect(options.conversationId).toBe("conv-9"); + // The button label is "Reprendre" when resume is supported. + expect(screen.queryByText("Reprendre")).toBeNull(); // row drained after action + }); + + it("Reprendre (resumeSupported === false) → 'relance à neuf' label + launch WITHOUT conversationId", async () => { + const { launchSpy } = renderPanel([ + entry({ + agentId: "a2", + nodeId: "node-7", + conversationId: "conv-ignored", + resumeSupported: false, + name: "Legacy", + }), + ]); + await screen.findByTestId("resume-project-panel"); + + // The "relance à neuf" wording is shown for an unsupported profile. + expect(screen.getByTestId("resume-fresh-note-a2").textContent).toContain( + "relance à neuf", + ); + // And the primary button reads "Relancer à neuf" (FR), not "Reprendre". + const btn = screen.getByLabelText("resume Legacy"); + expect(btn.textContent).toBe("Relancer à neuf"); + + fireEvent.click(btn); + + await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); + const [, , options] = launchSpy.mock.calls[0]; + expect(options.nodeId).toBe("node-7"); + // Unsupported ⇒ NO conversation id passed (launches fresh). + expect(options.conversationId).toBeUndefined(); + }); + + it("Nouvelle conversation → setCellConversation(nodeId, null) then launch without conversationId", async () => { + const { launchSpy, mutateSpy } = renderPanel([ + entry({ agentId: "a3", nodeId: "node-3", conversationId: "conv-3" }), + ]); + await screen.findByTestId("resume-project-panel"); + + fireEvent.click(screen.getByLabelText("new conversation Architect")); + + await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); + // setCellConversation cleared BEFORE the launch. + expect(mutateSpy).toHaveBeenCalledWith(PROJECT_ID, { + type: "setCellConversation", + target: "node-3", + conversationId: null, + }); + const mutateOrder = mutateSpy.mock.invocationCallOrder[0]; + const launchOrder = launchSpy.mock.invocationCallOrder[0]; + expect(mutateOrder).toBeLessThan(launchOrder); + // Launch carries no conversation id. + const [, , options] = launchSpy.mock.calls[0]; + expect(options.conversationId).toBeUndefined(); + }); + + it("Ignorer → drops the row locally, no launchAgent", async () => { + const { launchSpy } = renderPanel([ + entry({ agentId: "a4", name: "Doomed" }), + ]); + await screen.findByTestId("resume-project-panel"); + + fireEvent.click(screen.getByLabelText("ignore Doomed")); + + await waitFor(() => + expect(screen.queryByTestId("resume-row-a4")).toBeNull(), + ); + expect(launchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ResumeProjectPanel — global actions", () => { + it("Tout reprendre → launches every pending agent", async () => { + const { launchSpy } = renderPanel([ + entry({ agentId: "a1", nodeId: "n1" }), + entry({ agentId: "a2", nodeId: "n2", name: "Tester" }), + ]); + await screen.findByTestId("resume-project-panel"); + + fireEvent.click(screen.getByLabelText("resume all agents")); + + await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2)); + const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort(); + expect(launchedAgentIds).toEqual(["a1", "a2"]); + // Panel unmounts once the pending set drains. + await waitFor(() => + expect(screen.queryByTestId("resume-project-panel")).toBeNull(), + ); + }); + + it("Tout ignorer → no launch, panel unmounts", async () => { + const { launchSpy } = renderPanel([ + entry({ agentId: "a1" }), + entry({ agentId: "a2", name: "Tester" }), + ]); + await screen.findByTestId("resume-project-panel"); + + fireEvent.click(screen.getByLabelText("ignore all agents")); + + await waitFor(() => + expect(screen.queryByTestId("resume-project-panel")).toBeNull(), + ); + expect(launchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/agents/useResumeProject.ts b/frontend/src/features/agents/useResumeProject.ts new file mode 100644 index 0000000..270acd9 --- /dev/null +++ b/frontend/src/features/agents/useResumeProject.ts @@ -0,0 +1,181 @@ +/** + * `useResumeProject` — view-model for the reopen resume flow (L15 / lot B2, + * ARCHITECTURE §15.2). + * + * On project open, it pulls the read-only resumable inventory + * ({@link AgentGateway.listResumableAgents}) and drives the per-agent resume + * actions of the {@link ResumeProjectPanel}. The actual resume **reuses the + * existing `launch_agent` flow** — no new backend use case: + * + * - **Reprendre** → `launchAgent(nodeId, conversationId)` (resume the CLI + * conversation). For a profile without a usable session strategy + * (`resumeSupported === false`) the entry simply carries no `conversationId`, + * so the launch starts fresh ("relance à neuf"). + * - **Nouvelle conversation** → `setCellConversation(nodeId, null)` first (so the + * backend treats the cell as fresh and assigns a new id), then + * `launchAgent(nodeId)` without a conversation id. + * - **Ignorer** → drops the entry locally (no launch). + * + * The launch happens at the project level (no mounted xterm view yet): `onData` + * is a no-op sink — the hosting cell reattaches to the spawned live session when + * it mounts. It consumes the `agent` + `layout` gateways exclusively; never + * `invoke()` (ARCHITECTURE §1.3), so it stays testable with mocks. + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { GatewayError, ResumableAgent } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** Default PTY geometry used for an open-time (headless) resume launch. */ +const DEFAULT_ROWS = 24; +const DEFAULT_COLS = 80; + +/** What the {@link ResumeProjectPanel} needs from this hook. */ +export interface ResumeProjectViewModel { + /** The agents still pending a decision (drains as the user acts). */ + pending: ResumableAgent[]; + /** Last error message, or `null`. */ + error: string | null; + /** Whether a resume launch is in flight. */ + busy: boolean; + /** Resumes one agent (keeps its conversation id ⇒ Resume / fresh if unsupported). */ + resume: (agent: ResumableAgent) => Promise; + /** Starts a fresh conversation for one agent (clears the id, then launches). */ + newConversation: (agent: ResumableAgent) => Promise; + /** Dismisses one agent without launching it. */ + ignore: (agentId: string) => void; + /** Resumes every pending agent. */ + resumeAll: () => Promise; + /** Dismisses every pending agent without launching. */ + ignoreAll: () => void; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** A no-op output sink: the hosting cell reattaches to the live session later. */ +const NO_DATA = (_bytes: Uint8Array): void => {}; + +export function useResumeProject( + projectId: string, + cwd: string, +): ResumeProjectViewModel { + const { agent, layout } = useGateways(); + const [pending, setPending] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + // Pull the inventory once per (project) open. A failure degrades to an empty + // list (no panel), never surfaces — the per-cell fallback still works. + useEffect(() => { + if (!agent) return; + let cancelled = false; + agent + .listResumableAgents(projectId) + .then((list) => { + if (!cancelled) setPending(list); + }) + .catch(() => { + if (!cancelled) setPending([]); + }); + return () => { + cancelled = true; + }; + }, [agent, projectId]); + + /** Removes one agent from the pending set (after a decision). */ + const drop = useCallback((agentId: string) => { + setPending((prev) => prev.filter((a) => a.agentId !== agentId)); + }, []); + + /** Launches one agent, resuming `conversationId` when provided. */ + const launch = useCallback( + async (a: ResumableAgent, conversationId: string | undefined) => { + if (!agent) return; + await agent.launchAgent( + projectId, + a.agentId, + { + cwd, + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + conversationId, + nodeId: a.nodeId, + }, + NO_DATA, + ); + }, + [agent, projectId, cwd], + ); + + const resume = useCallback( + async (a: ResumableAgent) => { + setBusy(true); + setError(null); + try { + // A profile without a usable session strategy carries no conversation id + // ⇒ launches fresh ("relance à neuf"); otherwise resume the id. + const convId = a.resumeSupported ? a.conversationId : undefined; + await launch(a, convId ?? undefined); + drop(a.agentId); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [launch, drop], + ); + + const newConversation = useCallback( + async (a: ResumableAgent) => { + setBusy(true); + setError(null); + try { + // Clear the persisted id BEFORE launching so the backend assigns a fresh + // conversation (mirrors LayoutGrid's `onNewConversation`). + await layout.mutateLayout(projectId, { + type: "setCellConversation", + target: a.nodeId, + conversationId: null, + }); + await launch(a, undefined); + drop(a.agentId); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [layout, projectId, launch, drop], + ); + + const ignore = useCallback((agentId: string) => drop(agentId), [drop]); + + const resumeAll = useCallback(async () => { + // Snapshot the current pending set; `resume` drains it entry by entry. + const snapshot = pending; + for (const a of snapshot) { + // eslint-disable-next-line no-await-in-loop + await resume(a); + } + }, [pending, resume]); + + const ignoreAll = useCallback(() => setPending([]), []); + + return { + pending, + error, + busy, + resume, + newConversation, + ignore, + resumeAll, + ignoreAll, + }; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index c35f181..aee8938 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -32,7 +32,7 @@ import { useState } from "react"; import type { LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; -import { AgentsPanel } from "@/features/agents"; +import { AgentsPanel, ResumeProjectPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { MemoryPanel } from "@/features/memory"; @@ -339,6 +339,17 @@ export function ProjectsView() { )} + + {/* ── Reopen resume panel (§15.2) ── + Mounted per active project (keyed by id) so it re-pulls the resumable + inventory on every open/switch; it renders nothing when empty. */} + {active && ( + + )} ); } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 2308a9c..5e14503 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -31,6 +31,7 @@ import type { MemoryType, Project, ProfileAvailability, + ResumableAgent, Skill, SkillScope, Template, @@ -75,6 +76,14 @@ export interface ConversationDetails { export interface AgentGateway { /** Lists all agents belonging to the given project. */ listAgents(projectId: string): Promise; + /** + * Lists the agents that can be **resumed** when the project is (re)opened + * (ARCHITECTURE §15.2): a read-only inventory of agent cells that were running + * and/or carry a persisted conversation id at close time. Drives the + * `ResumeProjectPanel` shown once on open; an empty list ⇒ no panel. Pure + * inventory: no PTY is spawned by this call. + */ + listResumableAgents(projectId: string): Promise; /** * Lists the agents that currently own a live session and the cell hosting * each. Used to disable an agent already running in another cell (it cannot be