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:
@ -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<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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<LaunchAgent>,
|
||||
/// Hot-swap an agent's runtime profile, relaunching its live session in place (§15.1).
|
||||
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)
|
||||
/// 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,
|
||||
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user