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>
This commit is contained in:
@ -1160,7 +1160,7 @@ La **reprise effective** d'un agent choisi dans le panneau **ne crée aucun nouv
|
|||||||
|-------------------------|-------------------------|----------------------|
|
|-------------------------|-------------------------|----------------------|
|
||||||
| list_resumable_agents | { projectId } | ResumableAgentListDto|
|
| list_resumable_agents | { projectId } | ResumableAgentListDto|
|
||||||
```
|
```
|
||||||
`ResumableAgentListDto { agents: Vec<ResumableAgentDto> }`, `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> }`, `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
|
#### Frontend
|
||||||
- **Port UI** `AgentGateway.listResumableAgents(projectId): Promise<ResumableAgent[]>` (+ adapter Tauri + mock).
|
- **Port UI** `AgentGateway.listResumableAgents(projectId): Promise<ResumableAgent[]>` (+ adapter Tauri + mock).
|
||||||
|
|||||||
@ -16,7 +16,8 @@ use application::{
|
|||||||
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
|
||||||
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
|
||||||
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
|
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
|
||||||
ListMemoriesInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput,
|
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput,
|
||||||
|
OpenProjectInput,
|
||||||
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||||
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
|
||||||
@ -40,7 +41,7 @@ use crate::dto::{
|
|||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
|
||||||
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto,
|
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto,
|
||||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto,
|
RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, ResumableAgentListDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
|
||||||
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
@ -1075,6 +1076,32 @@ pub async fn change_agent_profile(
|
|||||||
.map_err(ErrorDto::from)
|
.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<ResumableAgentListDto, ErrorDto> {
|
||||||
|
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)
|
// Templates & sync (L7)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1305,6 +1305,72 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<String>,
|
||||||
|
/// 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<ResumableAgent> 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<ResumableAgentDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListResumableAgentsOutput> for ResumableAgentListDto {
|
||||||
|
fn from(out: ListResumableAgentsOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
resumable: out
|
||||||
|
.resumable
|
||||||
|
.into_iter()
|
||||||
|
.map(ResumableAgentDto::from)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Templates & sync (L7)
|
// Templates & sync (L7)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -127,6 +127,7 @@ pub fn run() {
|
|||||||
commands::delete_agent,
|
commands::delete_agent,
|
||||||
commands::launch_agent,
|
commands::launch_agent,
|
||||||
commands::change_agent_profile,
|
commands::change_agent_profile,
|
||||||
|
commands::list_resumable_agents,
|
||||||
commands::inspect_conversation,
|
commands::inspect_conversation,
|
||||||
commands::create_template,
|
commands::create_template,
|
||||||
commands::update_template,
|
commands::update_template,
|
||||||
|
|||||||
@ -18,7 +18,8 @@ use application::{
|
|||||||
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
|
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||||
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
|
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,
|
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
|
||||||
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
|
RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
|
||||||
@ -131,6 +132,9 @@ pub struct AppState {
|
|||||||
pub launch_agent: Arc<LaunchAgent>,
|
pub launch_agent: Arc<LaunchAgent>,
|
||||||
/// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1).
|
/// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1).
|
||||||
pub change_agent_profile: Arc<ChangeAgentProfile>,
|
pub change_agent_profile: Arc<ChangeAgentProfile>,
|
||||||
|
/// Read-only inventory of a project's resumable agent cells, for the reopen
|
||||||
|
/// panel (§15.2).
|
||||||
|
pub list_resumable_agents: Arc<ListResumableAgents>,
|
||||||
/// Best-effort inspection of a conversation (last topic + token indicator)
|
/// Best-effort inspection of a conversation (last topic + token indicator)
|
||||||
/// for the resume popup (T7). Optional/extensible: backed by a `Vec` of
|
/// for the resume popup (T7). Optional/extensible: backed by a `Vec` of
|
||||||
/// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty
|
/// [`domain::ports::SessionInspector`]s; an empty/missing match yields empty
|
||||||
@ -531,6 +535,15 @@ impl AppState {
|
|||||||
Arc::clone(&events_port),
|
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) ---
|
// --- Conversation inspection (T7) ---
|
||||||
// Best-effort, optional, extensible: a `Vec` of SessionInspectors routed
|
// Best-effort, optional, extensible: a `Vec` of SessionInspectors routed
|
||||||
// by profile. Adding an inspectable CLI = pushing one more adapter here.
|
// by profile. Adding an inspectable CLI = pushing one more adapter here.
|
||||||
@ -717,6 +730,7 @@ impl AppState {
|
|||||||
delete_agent,
|
delete_agent,
|
||||||
launch_agent,
|
launch_agent,
|
||||||
change_agent_profile,
|
change_agent_profile,
|
||||||
|
list_resumable_agents,
|
||||||
inspect_conversation,
|
inspect_conversation,
|
||||||
project_store,
|
project_store,
|
||||||
create_template,
|
create_template,
|
||||||
|
|||||||
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal file
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2):
|
||||||
|
//! - `From<ResumableAgent> 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<ListResumableAgentsOutput> 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<ResumableAgent> 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<String>,
|
||||||
|
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<ListResumableAgentsOutput> 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());
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { Agent, TerminalSession } from "@/domain";
|
import type { Agent, ResumableAgent, TerminalSession } from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
AgentGateway,
|
AgentGateway,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
@ -45,6 +45,15 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
return invoke<LiveAgent[]>("list_live_agents", { projectId });
|
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(
|
attachLiveAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
Template,
|
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<string, ResumableAgent[]>();
|
||||||
|
|
||||||
|
/** 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<ResumableAgent[]> {
|
||||||
|
return structuredClone(this.resumable.get(projectId) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
async attachLiveAgent(
|
async attachLiveAgent(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@ -273,6 +273,25 @@ export interface TerminalSession {
|
|||||||
assignedConversationId?: string;
|
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`.
|
// Skills (L12) — mirror of the domain `Skill` / `SkillRef`.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
154
frontend/src/features/agents/ResumeProjectPanel.tsx
Normal file
154
frontend/src/features/agents/ResumeProjectPanel.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Reprise des agents"
|
||||||
|
data-testid="resume-project-panel"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||||||
|
<h4 className="text-sm font-semibold text-content">
|
||||||
|
Reprendre les agents ?
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Ces agents tournaient à la dernière fermeture du projet. Choisissez
|
||||||
|
ceux à reprendre.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{vm.error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Per-agent rows ── */}
|
||||||
|
<ul className="mt-4 flex flex-col gap-2 overflow-auto">
|
||||||
|
{vm.pending.map((a) => {
|
||||||
|
const statusLabel = a.wasRunning ? "en cours" : "clôt";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.agentId}
|
||||||
|
data-testid={`resume-row-${a.agentId}`}
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate font-medium text-content">
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-testid={`resume-status-${a.agentId}`}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded px-1.5 py-0.5 text-xs",
|
||||||
|
a.wasRunning
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!a.resumeSupported && (
|
||||||
|
<p
|
||||||
|
data-testid={`resume-fresh-note-${a.agentId}`}
|
||||||
|
className="text-xs text-muted"
|
||||||
|
>
|
||||||
|
Historique non disponible pour ce moteur — relance à neuf.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
aria-label={`resume ${a.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.resume(a)}
|
||||||
|
>
|
||||||
|
{a.resumeSupported ? "Reprendre" : "Relancer à neuf"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
aria-label={`new conversation ${a.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.newConversation(a)}
|
||||||
|
>
|
||||||
|
Nouvelle conversation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`ignore ${a.name}`}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => vm.ignore(a.agentId)}
|
||||||
|
>
|
||||||
|
Ignorer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* ── Global actions ── */}
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="ignore all agents"
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => vm.ignoreAll()}
|
||||||
|
>
|
||||||
|
Tout ignorer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
aria-label="resume all agents"
|
||||||
|
loading={vm.busy}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onClick={() => void vm.resumeAll()}
|
||||||
|
>
|
||||||
|
Tout reprendre
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,3 +6,7 @@ export { AgentsPanel } from "./AgentsPanel";
|
|||||||
export type { AgentsPanelProps } from "./AgentsPanel";
|
export type { AgentsPanelProps } from "./AgentsPanel";
|
||||||
export { useAgents } from "./useAgents";
|
export { useAgents } from "./useAgents";
|
||||||
export type { AgentsViewModel } 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";
|
||||||
|
|||||||
283
frontend/src/features/agents/resumeProject.test.tsx
Normal file
283
frontend/src/features/agents/resumeProject.test.tsx
Normal file
@ -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> = {}): 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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<ResumeProjectPanel projectId={PROJECT_ID} cwd={CWD} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
181
frontend/src/features/agents/useResumeProject.ts
Normal file
181
frontend/src/features/agents/useResumeProject.ts
Normal file
@ -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<void>;
|
||||||
|
/** Starts a fresh conversation for one agent (clears the id, then launches). */
|
||||||
|
newConversation: (agent: ResumableAgent) => Promise<void>;
|
||||||
|
/** Dismisses one agent without launching it. */
|
||||||
|
ignore: (agentId: string) => void;
|
||||||
|
/** Resumes every pending agent. */
|
||||||
|
resumeAll: () => Promise<void>;
|
||||||
|
/** 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<ResumableAgent[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { LayoutInfo } from "@/domain";
|
import type { LayoutInfo } from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel, ResumeProjectPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { MemoryPanel } from "@/features/memory";
|
import { MemoryPanel } from "@/features/memory";
|
||||||
@ -339,6 +339,17 @@ export function ProjectsView() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 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 && (
|
||||||
|
<ResumeProjectPanel
|
||||||
|
key={`resume-${active.id}`}
|
||||||
|
projectId={active.id}
|
||||||
|
cwd={active.root}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
Project,
|
Project,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
|
ResumableAgent,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
Template,
|
Template,
|
||||||
@ -75,6 +76,14 @@ export interface ConversationDetails {
|
|||||||
export interface AgentGateway {
|
export interface AgentGateway {
|
||||||
/** Lists all agents belonging to the given project. */
|
/** Lists all agents belonging to the given project. */
|
||||||
listAgents(projectId: string): Promise<Agent[]>;
|
listAgents(projectId: string): Promise<Agent[]>;
|
||||||
|
/**
|
||||||
|
* 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<ResumableAgent[]>;
|
||||||
/**
|
/**
|
||||||
* Lists the agents that currently own a live session and the cell hosting
|
* 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
|
* each. Used to disable an agent already running in another cell (it cannot be
|
||||||
|
|||||||
Reference in New Issue
Block a user