diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index c64fd49..35b0a73 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -12,13 +12,13 @@ use application::{ AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, - DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, - GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, - InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, - ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, - McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput, - ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput, - ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, + DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GetProjectWorkStateInput, + GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, + GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, + ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, + LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, + RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, @@ -42,11 +42,11 @@ use crate::dto::{ InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto, - RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, + SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, + SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, @@ -968,6 +968,25 @@ pub async fn list_agents( .map_err(ErrorDto::from) } +/// `get_project_work_state` — read-only live/busy state for manifest agents. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `STORE` on manifest I/O failure). +#[tauri::command] +pub async fn get_project_work_state( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .get_project_work_state + .execute(GetProjectWorkStateInput { project }) + .await + .map(ProjectWorkStateDto::from) + .map_err(ErrorDto::from) +} + /// `list_live_agents` — list every agent that currently owns a live session /// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can /// disable an agent already running in another cell (the "one live session per diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index e6665a5..db026a2 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use application::{ AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, - LayoutKind, ListProjectsOutput, OpenProjectOutput, + LayoutKind, ListProjectsOutput, LiveSessionKind, OpenProjectOutput, ProjectWorkState, }; -use domain::{Project, ProjectId}; +use domain::{AgentBusyState, Project, ProjectId}; /// Request DTO for the `health` command. #[derive(Debug, Clone, Default, Deserialize)] @@ -1472,6 +1472,83 @@ impl LiveAgentListDto { } } +/// Runtime family of a live work-state session. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LiveWorkSessionKindDto { + /// Raw PTY-backed CLI session. + Pty, + /// Structured agent-session backend. + Structured, +} + +impl From for LiveWorkSessionKindDto { + fn from(kind: LiveSessionKind) -> Self { + match kind { + LiveSessionKind::Pty => Self::Pty, + LiveSessionKind::Structured => Self::Structured, + } + } +} + +/// Live session coordinates in the project work-state read model. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveWorkSessionDto { + /// The layout node currently hosting the session view. + pub node_id: String, + /// The live session id. + pub session_id: String, + /// Runtime family that owns the session. + pub kind: LiveWorkSessionKindDto, +} + +/// One manifest agent's current live/busy state. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentWorkStateDto { + /// Agent id. + pub agent_id: String, + /// Agent display name. + pub name: String, + /// Runtime profile id assigned to the agent. + pub profile_id: String, + /// Live session, if any. + pub live: Option, + /// Current mediated-input busy state. + pub busy: AgentBusyState, +} + +/// Project-level read model for conversation/delegation UX. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectWorkStateDto { + /// Manifest agents in manifest order, enriched with live/busy state. + pub agents: Vec, +} + +impl From for ProjectWorkStateDto { + fn from(state: ProjectWorkState) -> Self { + Self { + agents: state + .agents + .into_iter() + .map(|agent| AgentWorkStateDto { + agent_id: agent.agent_id.to_string(), + name: agent.name, + profile_id: agent.profile_id.to_string(), + live: agent.live.map(|live| LiveWorkSessionDto { + node_id: live.node_id.to_string(), + session_id: live.session_id.to_string(), + kind: live.kind.into(), + }), + busy: agent.busy, + }) + .collect(), + } + } +} + /// Request DTO for `attach_live_agent`: bind an already-running agent session to /// a visible layout cell without spawning a new process. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index c7bcd54..6e22a42 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -163,6 +163,7 @@ pub fn run() { commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, + commands::get_project_work_state, commands::list_live_agents, commands::attach_live_agent, commands::read_agent_context, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b5421ad..59625d9 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -18,15 +18,15 @@ use application::{ CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, - ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts, - RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal, - ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, + ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, + ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, + LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, + ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, + ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, @@ -330,6 +330,8 @@ pub struct AppState { /// Read-only inventory of a project's resumable agent cells, for the reopen /// panel (§15.2). pub list_resumable_agents: Arc, + /// Read-only live/busy state for the project's manifest agents. + pub get_project_work_state: 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 @@ -1021,6 +1023,15 @@ impl AppState { }); } let input_mediator = Arc::clone(&mediated_inbox) as Arc; + let live_sessions = Arc::new(LiveSessions::new( + Arc::clone(&terminal_sessions), + Arc::clone(&structured_sessions), + )); + let get_project_work_state = Arc::new(GetProjectWorkState::new( + Arc::clone(&contexts_port), + Arc::clone(&live_sessions), + Arc::clone(&input_mediator), + )); // --- Limites de session des agents (ARCHITECTURE §21, LS7) --- // Service pur-ports « détecter → planifier → reprendre » câblé sur l'existant : @@ -1187,6 +1198,7 @@ impl AppState { launch_agent, change_agent_profile, list_resumable_agents, + get_project_work_state, inspect_conversation, project_store, get_project_permissions, diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index fa48c98..71d9d39 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -9,7 +9,8 @@ use app_tauri_lib::dto::{ }; use application::AppError; use application::{ - CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, + AgentWorkState, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, + ListAgentsOutput, LiveSessionKind, LiveWorkSession, ProjectWorkState, }; use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ports::ConversationDetails; @@ -197,6 +198,44 @@ fn live_agent_list_dto_serialises_camelcase_array() { assert!(arr[0].get("node_id").is_none()); } +#[test] +fn project_work_state_dto_serialises_live_and_busy_camelcase() { + let agent = AgentId::from_uuid(Uuid::from_u128(11)); + let profile = ProfileId::from_uuid(Uuid::from_u128(12)); + let node = NodeId::from_uuid(Uuid::from_u128(13)); + let session = SessionId::from_uuid(Uuid::from_u128(14)); + let ticket = domain::TicketId::from_uuid(Uuid::from_u128(15)); + let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState { + agents: vec![AgentWorkState { + agent_id: agent, + name: "Worker".to_owned(), + profile_id: profile, + live: Some(LiveWorkSession { + node_id: node, + session_id: session, + kind: LiveSessionKind::Structured, + }), + busy: domain::AgentBusyState::Busy { + ticket, + since_ms: 1_234, + }, + }], + }); + + let v = serde_json::to_value(&dto).unwrap(); + assert_eq!(v["agents"][0]["agentId"], agent.to_string()); + assert_eq!(v["agents"][0]["profileId"], profile.to_string()); + assert_eq!(v["agents"][0]["live"]["nodeId"], node.to_string()); + assert_eq!(v["agents"][0]["live"]["sessionId"], session.to_string()); + assert_eq!(v["agents"][0]["live"]["kind"], "structured"); + assert_eq!(v["agents"][0]["busy"]["state"], "busy"); + assert_eq!(v["agents"][0]["busy"]["ticket"], ticket.to_string()); + assert_eq!(v["agents"][0]["busy"]["sinceMs"], 1_234); + assert!(v["agents"][0].get("agent_id").is_none()); + assert!(v["agents"][0]["live"].get("session_id").is_none()); + assert!(v["agents"][0]["busy"].get("since_ms").is_none()); +} + #[test] fn launch_agent_request_carries_conversation_id_for_resume() { let raw = json!({ diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index d1bd563..9a50497 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -28,6 +28,7 @@ pub mod skill; pub mod template; pub mod terminal; pub mod window; +pub mod workstate; pub use agent::{ drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles, @@ -111,8 +112,13 @@ pub use template::{ UpdateTemplateOutput, }; pub use terminal::{ - CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions, - OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, - StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput, + CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind, + LiveSessionSnapshot, LiveSessions, OpenTerminal, OpenTerminalInput, OpenTerminalOutput, + ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal, + WriteToTerminalInput, }; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; +pub use workstate::{ + AgentWorkState, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession, + ProjectWorkState, +}; diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index 9f250fc..84f7ee0 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -28,7 +28,10 @@ mod registry; mod usecases; -pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions}; +pub use registry::{ + LiveAgentRegistry, LiveSessionKind, LiveSessionSnapshot, LiveSessions, StructuredSessions, + TerminalSessions, +}; pub use usecases::{ CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput, diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 6c14c6e..a45e803 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -13,6 +13,28 @@ use domain::conversation::ConversationId; use domain::ports::{AgentSession, PtyHandle}; use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession}; +/// Runtime family of a live agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiveSessionKind { + /// Raw PTY-backed CLI session. + Pty, + /// Structured agent-session backend. + Structured, +} + +/// Read-only coordinates of one live agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LiveSessionSnapshot { + /// The agent owning the live session. + pub agent_id: AgentId, + /// The layout node currently hosting the session view. + pub node_id: NodeId, + /// The live session id. + pub session_id: SessionId, + /// Which runtime registry owns the session. + pub kind: LiveSessionKind, +} + /// A registered, live terminal: its PTY handle plus the domain snapshot. #[derive(Debug, Clone)] struct Entry { @@ -516,6 +538,31 @@ impl LiveSessions { all.extend(self.structured.live_agents()); all } + + /// Tous les agents vivants avec le type de registre source (PTY puis structuré). + #[must_use] + pub fn live_agent_snapshots(&self) -> Vec { + let mut all: Vec = self + .pty + .live_agents() + .into_iter() + .map(|(agent_id, node_id, session_id)| LiveSessionSnapshot { + agent_id, + node_id, + session_id, + kind: LiveSessionKind::Pty, + }) + .collect(); + all.extend(self.structured.live_agents().into_iter().map( + |(agent_id, node_id, session_id)| LiveSessionSnapshot { + agent_id, + node_id, + session_id, + kind: LiveSessionKind::Structured, + }, + )); + all + } } impl LiveAgentRegistry for LiveSessions { diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs new file mode 100644 index 0000000..897c0a8 --- /dev/null +++ b/crates/application/src/workstate/mod.rs @@ -0,0 +1,124 @@ +//! Read-only project work-state read model. +//! +//! This module composes existing runtime state only: the project agent manifest, +//! live session registries and the input mediator busy state. It performs no I/O +//! beyond loading the manifest through the existing context store and creates no +//! durable projection. + +use std::collections::HashMap; +use std::sync::Arc; + +use domain::input::AgentBusyState; +use domain::ports::AgentContextStore; +use domain::{AgentId, InputMediator, NodeId, ProfileId, Project, SessionId}; + +use crate::error::AppError; +use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions}; + +/// Input for [`GetProjectWorkState::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetProjectWorkStateInput { + /// Project whose manifest provides the agent order and boundary. + pub project: Project, +} + +/// Read model for a project's current agent work state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectWorkState { + /// Agents in manifest order. + pub agents: Vec, +} + +/// Read model for one manifest agent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentWorkState { + /// Agent id. + pub agent_id: AgentId, + /// Agent display name. + pub name: String, + /// Runtime profile id currently assigned to the agent. + pub profile_id: ProfileId, + /// Current live session, if the agent is live. + pub live: Option, + /// Current FIFO/busy state. + pub busy: AgentBusyState, +} + +/// Live session coordinates exposed by the work-state read model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LiveWorkSession { + /// The layout node currently hosting the session view. + pub node_id: NodeId, + /// The live session id. + pub session_id: SessionId, + /// Runtime family that owns the session. + pub kind: LiveSessionKind, +} + +/// Read-only use case aggregating manifest agents with live and busy state. +pub struct GetProjectWorkState { + contexts: Arc, + live: Arc, + input: Arc, +} + +impl GetProjectWorkState { + /// Builds the read-model use case from existing stores/registries. + #[must_use] + pub fn new( + contexts: Arc, + live: Arc, + input: Arc, + ) -> Self { + Self { + contexts, + live, + input, + } + } + + /// Executes the read-only aggregation. + /// + /// # Errors + /// - [`AppError::Store`] when the manifest cannot be loaded, + /// - [`AppError::Invalid`] if a manifest entry violates agent invariants. + pub async fn execute( + &self, + input: GetProjectWorkStateInput, + ) -> Result { + let manifest = self.contexts.load_manifest(&input.project).await?; + let live_by_agent = live_by_agent(self.live.live_agent_snapshots()); + let agents = manifest + .entries + .iter() + .map(|entry| { + let agent = entry + .to_agent() + .map_err(|err| AppError::Invalid(err.to_string()))?; + let live = live_by_agent + .get(&agent.id) + .map(|snapshot| LiveWorkSession { + node_id: snapshot.node_id, + session_id: snapshot.session_id, + kind: snapshot.kind, + }); + Ok(AgentWorkState { + agent_id: agent.id, + name: agent.name, + profile_id: agent.profile_id, + live, + busy: self.input.busy_state(agent.id), + }) + }) + .collect::, AppError>>()?; + Ok(ProjectWorkState { agents }) + } +} + +fn live_by_agent(snapshots: Vec) -> HashMap { + let mut out = HashMap::new(); + for snapshot in snapshots { + out.entry(snapshot.agent_id).or_insert(snapshot); + } + out +} diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs new file mode 100644 index 0000000..0be9502 --- /dev/null +++ b/crates/application/tests/workstate.rs @@ -0,0 +1,307 @@ +//! Tests for the project work-state read model. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use application::{ + GetProjectWorkState, GetProjectWorkStateInput, LiveSessionKind, LiveSessions, + StructuredSessions, TerminalSessions, +}; +use domain::mailbox::{MailboxError, PendingReply, Ticket}; +use domain::ports::{ + AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError, +}; +use domain::{ + Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, InputMediator, ManifestEntry, + MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, + SessionKind, TerminalSession, +}; +use uuid::Uuid; + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +fn pid(n: u128) -> ProfileId { + ProfileId::from_uuid(Uuid::from_u128(n)) +} + +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} + +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} + +fn ticket_id(n: u128) -> domain::TicketId { + domain::TicketId::from_uuid(Uuid::from_u128(n)) +} + +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::from_u128(1)), + "demo", + ProjectPath::new("/tmp/idea-workstate-test").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +fn agent(n: u128, name: &str) -> Agent { + Agent::new( + aid(n), + name, + format!("agents/{name}.md"), + pid(100 + n), + AgentOrigin::Scratch, + false, + ) + .unwrap() +} + +fn manifest(agents: &[Agent]) -> AgentManifest { + AgentManifest::new(1, agents.iter().map(ManifestEntry::from_agent).collect()).unwrap() +} + +#[derive(Clone)] +struct FakeContexts { + manifest: AgentManifest, +} + +#[async_trait] +impl AgentContextStore for FakeContexts { + async fn read_context( + &self, + _project: &Project, + _agent: &AgentId, + ) -> Result { + Err(StoreError::NotFound) + } + + async fn write_context( + &self, + _project: &Project, + _agent: &AgentId, + _md: &MarkdownDoc, + ) -> Result<(), StoreError> { + Ok(()) + } + + async fn load_manifest(&self, _project: &Project) -> Result { + Ok(self.manifest.clone()) + } + + async fn save_manifest( + &self, + _project: &Project, + _manifest: &AgentManifest, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeInput { + busy: Mutex>, +} + +impl FakeInput { + fn set_busy(&self, agent: AgentId, busy: AgentBusyState) { + self.busy.lock().unwrap().insert(agent, busy); + } +} + +impl InputMediator for FakeInput { + fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + let fut: Pin> + Send>> = + Box::pin(async { Err(MailboxError::Cancelled) }); + PendingReply::new(fut) + } + + fn preempt(&self, _agent: AgentId) {} + + fn mark_idle(&self, _agent: AgentId) {} + + fn busy_state(&self, agent: AgentId) -> AgentBusyState { + self.busy + .lock() + .unwrap() + .get(&agent) + .copied() + .unwrap_or(AgentBusyState::Idle) + } +} + +struct FakeSession { + id: SessionId, +} + +#[async_trait] +impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + None + } + + async fn send(&self, _prompt: &str) -> Result { + Ok(Box::new(std::iter::empty())) + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +fn fake_session(id: SessionId) -> Arc { + Arc::new(FakeSession { id }) +} + +fn insert_pty( + sessions: &TerminalSessions, + session_id: SessionId, + agent_id: AgentId, + node_id: NodeId, +) { + sessions.insert( + PtyHandle { session_id }, + TerminalSession::starting( + session_id, + node_id, + ProjectPath::new("/tmp/idea-workstate-test").unwrap(), + SessionKind::Agent { agent_id }, + PtySize { rows: 24, cols: 80 }, + ), + ); +} + +struct Fixture { + usecase: GetProjectWorkState, + pty: Arc, + structured: Arc, + input: Arc, + project: Project, +} + +fn fixture(agents: &[Agent]) -> Fixture { + let pty = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured))); + let input = Arc::new(FakeInput::default()); + let input_port = Arc::clone(&input) as Arc; + let usecase = GetProjectWorkState::new( + Arc::new(FakeContexts { + manifest: manifest(agents), + }), + live, + input_port, + ); + Fixture { + usecase, + pty, + structured, + input, + project: project(), + } +} + +#[tokio::test] +async fn workstate_lists_manifest_agents_idle_without_live_sessions() { + let a = agent(10, "alpha"); + let b = agent(20, "beta"); + let f = fixture(&[a.clone(), b.clone()]); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + assert_eq!(out.agents.len(), 2); + assert_eq!(out.agents[0].agent_id, a.id); + assert_eq!(out.agents[0].name, "alpha"); + assert_eq!(out.agents[0].profile_id, a.profile_id); + assert_eq!(out.agents[0].live, None); + assert_eq!(out.agents[0].busy, AgentBusyState::Idle); + assert_eq!(out.agents[1].agent_id, b.id); +} + +#[tokio::test] +async fn workstate_attaches_live_pty_session_to_manifest_agent() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + insert_pty(&f.pty, sid(1), a.id, nid(100)); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let live = out.agents[0].live.unwrap(); + assert_eq!(live.session_id, sid(1)); + assert_eq!(live.node_id, nid(100)); + assert_eq!(live.kind, LiveSessionKind::Pty); +} + +#[tokio::test] +async fn workstate_attaches_live_structured_session_to_manifest_agent() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + f.structured.insert(fake_session(sid(2)), a.id, nid(200)); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + let live = out.agents[0].live.unwrap(); + assert_eq!(live.session_id, sid(2)); + assert_eq!(live.node_id, nid(200)); + assert_eq!(live.kind, LiveSessionKind::Structured); +} + +#[tokio::test] +async fn workstate_includes_busy_state_from_input_mediator() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + let busy = AgentBusyState::Busy { + ticket: ticket_id(7), + since_ms: 1_234, + }; + f.input.set_busy(a.id, busy); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + assert_eq!(out.agents[0].busy, busy); +} + +#[tokio::test] +async fn workstate_ignores_live_agents_absent_from_manifest() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + insert_pty(&f.pty, sid(99), aid(999), nid(999)); + + let out = f + .usecase + .execute(GetProjectWorkStateInput { project: f.project }) + .await + .unwrap(); + + assert_eq!(out.agents.len(), 1); + assert_eq!(out.agents[0].agent_id, a.id); + assert_eq!(out.agents[0].live, None); +}