diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 4f00d72..61875ea 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -18,21 +18,22 @@ use application::{ CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, - GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, - HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, - ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, - OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, - ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, - RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, - SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, - UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, + GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, + GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, + ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, + ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, + ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, + UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, + WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, @@ -153,6 +154,44 @@ impl LiveStateProvider for AppLiveStateProvider { } } +/// Implémente [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et** +/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un +/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant. +/// +/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et +/// l'[`OrchestratorService`] sont uniques pour tous les projets, alors que le live-state +/// est **par project root** (`/.ideai/live-state.json`) et le store fixe sa racine +/// à la construction. On construit donc un `GetLiveStateLean` frais par appel, ciblant le +/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]). +struct AppLiveStateLeanProvider { + clock: Arc, +} + +impl AppLiveStateLeanProvider { + fn getter(&self, root: &domain::project::ProjectPath) -> Arc { + let store = Arc::new(FsLiveStateStore::new(root)); + Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock))) + } +} + +impl LiveStateLeanProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + +impl LiveStateReadProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + /// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot /// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du /// lancement en cours. @@ -856,7 +895,13 @@ impl AppState { // Projection des permissions au (re)lancement (lot LP3-5) : avec le // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur // du profil matérialise la config de permission de la CLI dans le run dir. - .with_permission_projectors(Arc::clone(&permission_projectors)), + .with_permission_projectors(Arc::clone(&permission_projectors)) + // Aperçu live-state des autres agents (lot LS4) : section `# État du projet` + // injectée au lancement. Best-effort strict : provider absent / erreur / + // parse ⇒ section omise, jamais d'échec de lancement. + .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc), ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ @@ -1227,7 +1272,13 @@ impl AppState { // délégation. .with_live_state(Arc::new(AppLiveStateProvider { clock: Arc::clone(&clock) as Arc, - }) as Arc), + }) as Arc) + // Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot + // lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe + // sa racine à la construction). Sans ça, l'outil renverrait « not configured ». + .with_live_state_read(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc), // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc @@ -3083,6 +3134,9 @@ mod mcp_serve_peer_tests { "idea_memory_write", // Skill-awareness : lecture à la demande du corps d'un skill. "idea_skill_read", + // Live-state (programme live-state, lot LS4). + "idea_workstate_read", + "idea_workstate_set", ] { assert!( names.contains(&expected), @@ -3091,8 +3145,8 @@ mod mcp_serve_peer_tests { } assert_eq!( tools.len(), - 12, - "exactly the twelve idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read); got {names:?}" + 14, + "exactly the fourteen idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read + 2 live-state LS4); got {names:?}" ); drop(client); // EOF ⇒ serve loop ends @@ -3529,6 +3583,133 @@ mod mcp_serve_peer_tests { ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n))) } + // ----------------------------------------------------------------------- + // LS4 — live-state round-trip through the app-tauri-wired service. + // ----------------------------------------------------------------------- + + /// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip + /// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the + /// service's write/read providers without touching the filesystem. + #[derive(Default)] + struct InMemLiveStore { + state: Mutex, + } + #[async_trait] + impl domain::ports::LiveStateStore for InMemLiveStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } + } + + struct WsWriteProvider { + update: Arc, + } + impl application::LiveStateProvider for WsWriteProvider { + fn live_state_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.update)) + } + } + struct WsReadProvider { + getter: Arc, + } + impl application::LiveStateReadProvider for WsReadProvider { + fn live_state_lean_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.getter)) + } + } + + /// Like [`build_service_with_guard`] but additively wires the LS4 write+read + /// providers over a SHARED in-memory store, exactly the prod builder's + /// `.with_live_state(...).with_live_state_read(...)`. + fn build_service_with_live_state(contexts: FakeContexts) -> Arc { + let store = Arc::new(InMemLiveStore::default()) as Arc; + let clock = Arc::new(FixedClock) as Arc; + let write = Arc::new(WsWriteProvider { + update: Arc::new(application::UpdateLiveState::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let read = Arc::new(WsReadProvider { + getter: Arc::new(application::GetLiveStateLean::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let service = build_service(contexts); + let service = Arc::try_unwrap(service) + .map_err(|_| ()) + .expect("freshly built service is uniquely owned") + .with_live_state(write as Arc) + .with_live_state_read(read as Arc); + Arc::new(service) + } + + /// END-TO-END (lot LS4) — through the **app-tauri-wired** `OrchestratorService`, + /// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's + /// row comes back with the declared status/intent and the **resolved display name** + /// (via `ListAgents`), and `progress` never surfaces. + #[tokio::test] + async fn wired_serves_workstate_set_then_read_round_trip() { + let proj = project(); + let contexts = FakeContexts::new(); + let agent = contexts.seed_agent("architect"); + let service = build_service_with_live_state(contexts); + + // set — keyed on the agent identity; a progress note is supplied (must not leak). + let ack = service + .dispatch( + &proj, + domain::OrchestratorCommand::SetWorkState { + agent, + status: domain::live_state::WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("hidden note".to_owned()), + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set must succeed when live-state is wired"); + assert!(ack.reply.is_none(), "set is ACK only: {ack:?}"); + + // read — over the SAME store, the architect's row comes back resolved. + let out = service + .dispatch( + &proj, + domain::OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read must succeed when live-state is wired"); + let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap(); + let rows = json.as_array().expect("array"); + assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}"); + assert_eq!( + rows[0]["agent"], + json!("architect"), + "name resolved: {json}" + ); + assert_eq!(rows[0]["status"], json!("working")); + assert_eq!(rows[0]["intent"], json!("ship LS4")); + assert!( + rows[0].get("progress").is_none(), + "progress must never surface on read: {json}" + ); + } + /// WIRING — with `.with_context_guard(...)`, `memory.write` then /// `memory.read` round-trips the content instead of erroring "not /// configured". Proves the four use cases reached the dispatch. diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c755a19..2cc1b10 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -30,10 +30,13 @@ use domain::{ SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; +use domain::live_state::WorkStatus; + use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; +use crate::workstate::GetLiveStateLean; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; @@ -85,6 +88,45 @@ pub trait ProviderSessionProvider: Send + Sync { ) -> Option>; } +/// Cap on the number of live-state rows injected into the convention file at +/// activation (lot LS4). A project runs a handful of agents; `16` bounds the +/// injected section so a transient fan-out never bloats every agent's context. +pub const LIVE_STATE_INJECT_MAX: usize = 16; + +/// One distilled live-state row ready to inject into the convention file's +/// `# État du projet` section (lot LS4). +/// +/// Deliberately minimal: the **other** agent's display name, its coarse status and +/// a short intent — never the ticket/lastDelegation UUIDs, `progress`, or any +/// transcript. Built by [`LaunchAgent::resolve_live_state`] (which owns the manifest +/// join + ordering + self-exclusion) so [`compose_convention_file`] stays pure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InjectedLiveRow { + /// The other agent's display name. + pub name: String, + /// Coarse status at a glance (rendered as its [`WorkStatus::label`]). + pub status: WorkStatus, + /// Short intent free-text; empty ⇒ the intent suffix is omitted. + pub intent: String, +} + +/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours +/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé. +/// +/// Même tension/réponse que [`HandoffProvider`] : [`LaunchAgent`] est unique pour tous +/// les projets, alors que le live-state est **par project root** +/// (`/.ideai/live-state.json`) et le port [`domain::ports::LiveStateStore`] fixe +/// sa racine à la construction. On matérialise donc un `GetLiveStateLean` ciblant le +/// **bon** dossier à chaque résolution (prune-on-read + snapshot lean). +/// +/// `None` ⇒ aucune injection d'état (best-effort absente) : zéro régression pour les +/// call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`. +pub trait LiveStateLeanProvider: Send + Sync { + /// Construit le [`GetLiveStateLean`] dont le store cible `root`. Appelé une fois par + /// lancement best-effort ; `None` ⇒ on saute silencieusement l'injection. + fn live_state_lean_for(&self, root: &ProjectPath) -> Option>; +} + // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- @@ -1080,6 +1122,12 @@ pub struct LaunchAgent { /// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection /// (comportement historique préservé pour les call sites/tests legacy). projectors: Option>, + /// Provider du [`GetLiveStateLean`] **par project root** (lot LS4), pour injecter + /// l'aperçu `# État du projet` (que font les autres agents) dans le convention file + /// au lancement. Injecté via [`Self::with_live_state_lean`] ; `None` ⇒ aucune + /// injection (zéro régression). Best-effort strict : provider absent / erreur / + /// parse ⇒ section omise, jamais d'échec de lancement. + live_state_lean: Option>, } impl LaunchAgent { @@ -1117,9 +1165,20 @@ impl LaunchAgent { handoffs: None, provider_sessions: None, projectors: None, + live_state_lean: None, } } + /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu + /// `# État du projet` (status + intent des autres agents) est injecté dans le + /// convention file. Sans cet appel (cas legacy / tests existants), aucune section + /// n'est injectée — signature de [`Self::new`] **inchangée**. Builder additif. + #[must_use] + pub fn with_live_state_lean(mut self, provider: Arc) -> Self { + self.live_state_lean = Some(provider); + self + } + /// Injects the per-CLI permission **projector registry** (lot LP3-3) used to /// translate the resolved [`EffectivePermissions`] into the launched CLI's /// concrete permission config. Without this call (legacy call sites / tests), @@ -1262,6 +1321,58 @@ impl LaunchAgent { store.load(conversation).await.ok().flatten() } + /// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à + /// injecter dans la section `# État du projet`, calqué sur [`Self::resolve_handoff`]. + /// + /// Ordre **du manifeste** (déterministe). L'agent lancé (`self_agent_id`) est + /// **exclu** (il ne se voit pas lui-même). Les lignes du live-state qui ne + /// correspondent à **aucun** agent du manifeste sont droppées (la frontière du + /// manifeste prime). Le résultat est borné à [`LIVE_STATE_INJECT_MAX`]. + /// + /// **Jamais bloquant** : provider absent, erreur du store, ou parse en échec + /// dégradent vers un `Vec` vide (section omise), exactement comme une mémoire/reprise + /// absente — un lancement n'est jamais cassé par cet aperçu. + async fn resolve_live_state( + &self, + root: &ProjectPath, + manifest: &AgentManifest, + self_agent_id: AgentId, + ) -> Vec { + let Some(provider) = self.live_state_lean.as_ref() else { + return Vec::new(); + }; + let Some(getter) = provider.live_state_lean_for(root) else { + return Vec::new(); + }; + // Toute erreur de lecture/prune ⇒ pas d'aperçu (best-effort). + let Ok(lean) = getter.execute().await else { + return Vec::new(); + }; + let by_agent: HashMap = + lean.entries.iter().map(|e| (e.agent_id, e)).collect(); + + let mut rows = Vec::new(); + for entry in &manifest.entries { + if rows.len() >= LIVE_STATE_INJECT_MAX { + break; + } + // Exclure la ligne de l'agent lancé : il ne s'observe pas lui-même. + if entry.agent_id == self_agent_id { + continue; + } + // Droppe les lignes live-state hors manifeste (on n'itère que le manifeste). + let Some(live) = by_agent.get(&entry.agent_id) else { + continue; + }; + rows.push(InjectedLiveRow { + name: entry.name.clone(), + status: live.status, + intent: live.intent.clone(), + }); + } + rows + } + /// Reads the shared project context from `.ideai/CONTEXT.md`. /// /// A missing file is normal for existing projects and simply omits the @@ -1472,6 +1583,12 @@ impl LaunchAgent { let handoff = self .resolve_handoff(&input.project.root, input.conversation_id.as_deref()) .await; + // Aperçu live-state des autres agents (lot LS4) : best-effort, additif. Injecté + // comme section `# État du projet`. Ordre manifeste, self exclu, borné, vide ⇒ + // section omise. Indépendant du handoff/mémoire. + let live_rows = self + .resolve_live_state(&input.project.root, &manifest, agent.id) + .await; // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent // has just read the project memory, so this is the moment to check whether a // semantic embedder would now help. Fully isolated from the launch outcome — @@ -1492,6 +1609,7 @@ impl LaunchAgent { &skills, &memory, handoff.as_ref(), + &live_rows, profile.mcp.is_some(), &mut spec, ) @@ -1970,6 +2088,7 @@ impl LaunchAgent { skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, + live_rows: &[InjectedLiveRow], mcp_enabled: bool, spec: &mut SpawnSpec, ) -> Result<(), AppError> { @@ -1989,6 +2108,7 @@ impl LaunchAgent { skills, memory, handoff, + live_rows, mcp_enabled, ); let path = RemotePath::new(join(&spec.cwd, &target)); @@ -2589,6 +2709,7 @@ pub(crate) fn compose_convention_file( skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, + live_rows: &[InjectedLiveRow], mcp_enabled: bool, ) -> String { let mut out = String::new(); @@ -2742,6 +2863,31 @@ pub(crate) fn compose_convention_file( } } + // État du projet (lot LS4) : aperçu lean de ce que font les autres agents en ce + // moment, placé APRÈS la mémoire projet et AVANT la reprise de conversation. Une + // ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais + // d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒ + // section entièrement omise (document octet-identique à sans-section). + if !live_rows.is_empty() { + out.push_str("\n\n---\n\n# État du projet\n\n"); + out.push_str( + "Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \ + non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \ + `idea_workstate_read` / `idea_workstate_set`.\n\n", + ); + for row in live_rows { + out.push_str("- **"); + out.push_str(&row.name); + out.push_str("** — "); + out.push_str(row.status.label()); + if !row.intent.trim().is_empty() { + out.push_str(" · "); + out.push_str(row.intent.trim()); + } + out.push('\n'); + } + } + // Reprise conversationnelle (lot P7) : section finale, la plus situationnelle // (« où on en était »), placée après la mémoire projet. Omise sans handoff. if let Some(handoff) = handoff { @@ -2866,6 +3012,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2896,6 +3043,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2930,6 +3078,7 @@ mod tests { ], &[], None, + &[], false, ); @@ -2966,6 +3115,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2984,8 +3134,16 @@ mod tests { // file-protocol modes, positioned right after the Skills awareness and // before the orchestration block's closing separator / project context. for mcp_enabled in [true, false] { - let doc = - compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled); + let doc = compose_convention_file( + "/root", + "ctx", + "# Persona", + &[], + &[], + None, + &[], + mcp_enabled, + ); assert!( doc.contains("**Mémoire IdeA**"), "memory harvest directive present (mcp_enabled={mcp_enabled})" @@ -3009,14 +3167,15 @@ mod tests { #[test] fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() { - let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!(mcp_doc.contains("idea_create_skill")); assert!( !mcp_doc.contains("skill.create"), "MCP awareness must point to the native IdeA tool" ); - let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file_doc = + compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!(file_doc.contains("skill.create")); assert!( !file_doc.contains("idea_create_skill"), @@ -3051,6 +3210,7 @@ mod tests { ], &[], None, + &[], true, // mcp_enabled ); @@ -3084,7 +3244,7 @@ mod tests { // NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA » // (toujours présent), donc on n'asserte plus son absence — seulement celle de la // section d'affordances, qui, elle, dépend bien des skills assignés. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!(!doc.contains("# Skills disponibles")); } @@ -3102,8 +3262,16 @@ mod tests { fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. - let with_empty = - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false); + let with_empty = compose_convention_file( + "/root", + "", + "# Persona\n\nDo X.", + &[], + &[], + None, + &[], + false, + ); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" @@ -3112,7 +3280,16 @@ mod tests { // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false) + compose_convention_file( + "/root", + "", + "# Persona\n\nDo X.", + &[], + &[], + None, + &[], + false + ) ); } @@ -3133,6 +3310,7 @@ mod tests { ), ], None, + &[], false, ); @@ -3171,6 +3349,7 @@ mod tests { std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], None, + &[], false, ); @@ -3190,7 +3369,7 @@ mod tests { fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping // the ban on the provider's native subagents (cadrage v3, Décision 3). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); // Native IdeA orchestration tools surfaced. assert!( @@ -3215,7 +3394,7 @@ mod tests { fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { // B-5 — the solicited-agent side of the protocol: a delegated task arrives as // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!( doc.contains("idea_reply"), "MCP prose must instruct answering via idea_reply" @@ -3226,7 +3405,8 @@ mod tests { ); // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply // instruction (zero regression on the file path). - let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file_doc = + compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!(!file_doc.contains("idea_reply")); } @@ -3234,7 +3414,7 @@ mod tests { fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // and crucially WITHOUT the `idea_*` tools (zero regression). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( doc.contains(".ideai/requests"), @@ -3257,7 +3437,7 @@ mod tests { // est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire, // contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand // même être décrites, avec les noms des outils MCP correspondants. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); // Capacité contexte projet. assert!( @@ -3283,7 +3463,7 @@ mod tests { // appliquée directement. On asserte sur la nuance réellement écrite par le dev : // « single-writer » + le fait qu'une proposition sans `target` est enregistrée // « pour validation » (et donc « n'est PAS appliquée directement »). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!( doc.contains("single-writer"), @@ -3303,7 +3483,7 @@ mod tests { fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() { // Surface SANS MCP : mêmes 3 capacités décrites UNIQUEMENT via les fichiers // `.ideai/` — et AUCUN nom d'outil `idea_*` (cloisonnement strict des surfaces). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); // Capacités décrites par leurs fichiers/dossiers. assert!( @@ -3351,8 +3531,8 @@ mod tests { fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() { // Cloisonnement : la prose MCP ne mentionne pas le protocole fichier // `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil. - let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); - let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); + let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( !mcp.contains(".ideai/requests"), @@ -3382,7 +3562,7 @@ mod tests { fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() { // Ordre : le briefing capacités apparaît AVANT le persona dans les 2 modes. // On ancre le brief sur un marqueur stable propre à chaque surface. - let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); let mcp_brief_at = mcp .find("idea_context_read") .expect("MCP capability brief present"); @@ -3392,7 +3572,7 @@ mod tests { "MCP capability brief must come before the persona" ); - let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); let file_brief_at = file .find(".ideai/CONTEXT.md") .expect("file capability brief present"); @@ -3478,7 +3658,8 @@ mod tests { MemoryType::Project, )]; let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7")); - let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false); + let doc = + compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), &[], false); assert!( doc.contains("# Reprise de la conversation"), @@ -3514,7 +3695,7 @@ mod tests { // objective = None ⇒ the section and the summary are present, but NO // `**Objectif :**` line. let h = handoff("Juste le résumé.", None); - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false); assert!( doc.contains("# Reprise de la conversation"), @@ -3532,7 +3713,7 @@ mod tests { // objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective // line, but the section + summary still render. let h = handoff("Le résumé.", Some(" ")); - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false); assert!(doc.contains("# Reprise de la conversation")); assert!(doc.contains("Le résumé.")); @@ -3546,7 +3727,7 @@ mod tests { fn compose_convention_file_without_handoff_omits_resume_section() { // handoff = None ⇒ no `# Reprise de la conversation` section at all // (zero regression for launches with no resume). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( !doc.contains("# Reprise de la conversation"), "no handoff ⇒ no resume section: {doc}" @@ -3557,6 +3738,118 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // État du projet (lot LS4) — live-state preview section in the composer. + // The composer is pure: it renders the already-distilled `InjectedLiveRow`s + // (self-exclusion/cap/ordering are `resolve_live_state`'s job, covered by the + // launch-level test). Here we pin the rendering contract. + // ----------------------------------------------------------------------- + + fn live_row(name: &str, status: WorkStatus, intent: &str) -> InjectedLiveRow { + InjectedLiveRow { + name: name.to_owned(), + status, + intent: intent.to_owned(), + } + } + + #[test] + fn compose_convention_file_renders_live_state_rows_in_order_with_labels() { + let rows = [ + live_row("Architect", WorkStatus::Working, "cadrage LS4"), + live_row("DevBackend", WorkStatus::Blocked, "attend la review"), + // Empty intent ⇒ the ` · intent` suffix is omitted (status only). + live_row("QA", WorkStatus::Idle, ""), + ]; + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &rows, true); + + // Section present, after the persona. + assert!(doc.contains("# État du projet"), "section present: {doc}"); + let persona_at = doc.find("# Persona").unwrap(); + let section_at = doc.find("# État du projet").unwrap(); + assert!( + persona_at < section_at, + "live-state comes after the persona" + ); + + // Exact line format `- **Name** — status[ · intent]`, status = WorkStatus::label. + assert!( + doc.contains("- **Architect** — working · cadrage LS4"), + "working row with intent: {doc}" + ); + assert!( + doc.contains("- **DevBackend** — blocked · attend la review"), + "blocked row with intent: {doc}" + ); + // QA has an empty intent ⇒ no ` · ` suffix, no trailing separator. + assert!( + doc.contains("- **QA** — idle\n"), + "idle row, intent omitted: {doc}" + ); + assert!( + !doc.contains("**QA** — idle ·"), + "empty intent must not render a ` · ` suffix: {doc}" + ); + + // Manifest order is preserved: Architect before DevBackend before QA. + let a = doc.find("**Architect**").unwrap(); + let b = doc.find("**DevBackend**").unwrap(); + let c = doc.find("**QA**").unwrap(); + assert!(a < b && b < c, "rows rendered in the given order: {doc}"); + + // No leakage: the section never carries UUIDs/progress/transcripts (none in the + // rows ⇒ none in the output). + assert!(!doc.contains("progress"), "progress never surfaced: {doc}"); + } + + #[test] + fn compose_convention_file_empty_live_state_is_identical_to_no_section() { + // No live rows ⇒ the `# État du projet` section is omitted entirely, and the + // document is byte-for-byte identical to a composition that never had one. + let baseline = + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true); + assert!( + !baseline.contains("# État du projet"), + "no live rows ⇒ no section" + ); + // Strict byte equality: threading an empty slice changes nothing. + let empty = + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true); + assert_eq!(baseline, empty, "empty live-state ⇒ identical document"); + } + + #[test] + fn compose_convention_file_live_state_placed_after_memory_before_resume() { + // Placement contract: État du projet sits AFTER `# Mémoire projet` and BEFORE + // `# Reprise de la conversation`. + let memory = [mem( + "git-optional", + "Git optionnel", + "git reste un simple tool", + MemoryType::Project, + )]; + let h = handoff("Résumé : étape 2 finie.", Some("Livrer LS4")); + let rows = [live_row("Architect", WorkStatus::Waiting, "revue")]; + let doc = compose_convention_file( + "/root", + "", + "# Persona", + &[], + &memory, + Some(&h), + &rows, + true, + ); + + let memory_at = doc.find("# Mémoire projet").unwrap(); + let live_at = doc.find("# État du projet").unwrap(); + let resume_at = doc.find("# Reprise de la conversation").unwrap(); + assert!( + memory_at < live_at && live_at < resume_at, + "order must be Mémoire → État du projet → Reprise: {doc}" + ); + } + // NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed — // that translation now lives in `infrastructure::permission::ClaudePermissionProjector` // and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 43d7867..95b0d98 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -29,10 +29,11 @@ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversa pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, - ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, + ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, + StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX, }; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 3050d35..c7b702e 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -37,15 +37,15 @@ pub use agent::{ ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, - InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, - LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, - ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, - ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, - SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, - RESUME_PROMPT, + InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, + ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, + ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, + StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use conversation::RecordTurn; pub use embedder::{ @@ -81,8 +81,9 @@ pub use memory::{ UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use orchestrator::{ - ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome, - OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, + ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, + OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory, + RecordTurnProvider, WriteMemory, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index 768b0db..963fbed 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -12,6 +12,6 @@ pub use context_guard::{ ReadMemoryInput, WriteMemory, WriteMemoryInput, }; pub use service::{ - ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome, - OrchestratorService, RecordTurnProvider, + ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, + OrchestratorOutcome, OrchestratorService, RecordTurnProvider, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 70dd4ba..1474e9c 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -45,7 +45,7 @@ use crate::orchestrator::{ }; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; -use crate::workstate::{UpdateLiveState, UpdateLiveStateInput}; +use crate::workstate::{GetLiveStateLean, UpdateLiveState, UpdateLiveStateInput}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows @@ -235,6 +235,23 @@ pub trait LiveStateProvider: Send + Sync { fn live_state_for(&self, root: &ProjectPath) -> Option>; } +/// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir +/// l'outil de lecture `idea_workstate_read`. +/// +/// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est +/// **unique** pour tous les projets, mais le live-state est **par project root** et le +/// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate` +/// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le +/// **bon** dossier (prune-on-read + snapshot lean). +/// +/// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts). +/// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge). +pub trait LiveStateReadProvider: Send + Sync { + /// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil + /// `idea_workstate_read` n'est pas servi pour ce root. + fn live_state_lean_for(&self, root: &ProjectPath) -> Option>; +} + /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, @@ -337,6 +354,12 @@ pub struct OrchestratorService { /// régression). **Best-effort strict** : un échec de persistance ne transforme /// **jamais** un succès de délégation/reply en erreur. live_state: Option>, + /// Provider de lecture du **live-state** (lot LS4) pour l'outil + /// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture + /// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent. + /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur + /// typée « not configured » (call sites/tests legacy restent verts). + live_state_read: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -403,6 +426,7 @@ impl OrchestratorService { memory_harvest: None, read_skill: None, live_state: None, + live_state_read: None, } } @@ -529,6 +553,16 @@ impl OrchestratorService { self } + /// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil + /// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ; + /// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car + /// le live-state est par project root et le port fixe son root à la construction. + #[must_use] + pub fn with_live_state_read(mut self, provider: Arc) -> Self { + self.live_state_read = Some(provider); + self + } + /// Distille l'objectif d'un ticket en un `intent` court pour le live-state : /// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`] /// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne @@ -729,6 +763,28 @@ impl OrchestratorService { content, requester, } => self.write_memory(project, slug, content, requester).await, + OrchestratorCommand::ReadWorkState { requester } => { + self.read_workstate(project, requester).await + } + OrchestratorCommand::SetWorkState { + agent, + status, + intent, + progress, + ticket, + last_delegation, + } => { + self.set_workstate( + project, + agent, + status, + intent, + progress, + ticket, + last_delegation, + ) + .await + } } } @@ -843,6 +899,105 @@ impl OrchestratorService { }) } + /// `workstate.read` / `idea_workstate_read` → the project's agent **live-state** + /// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display + /// name, returned inline as a JSON array. Entries whose agent is no longer in the + /// manifest are dropped; `progress` is **never** exposed (the lean DTO has none). + /// + /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`. + async fn read_workstate( + &self, + project: &Project, + _requester: ConversationParty, + ) -> Result { + let provider = self + .live_state_read + .as_deref() + .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; + let getter = provider + .live_state_lean_for(&project.root) + .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; + let lean = getter.execute().await?; + + // Cross with the manifest to enrich each row with the agent's display name and + // to drop rows whose agent left the manifest (boundary preserved). + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + let names: HashMap = + listed.agents.into_iter().map(|a| (a.id, a.name)).collect(); + + let rows: Vec = lean + .entries + .into_iter() + .filter_map(|entry| { + let name = names.get(&entry.agent_id)?; + let mut row = serde_json::json!({ + "agent": name, + "status": entry.status, + "intent": entry.intent, + }); + if let Some(ticket) = entry.ticket { + row["ticket"] = serde_json::json!(ticket); + } + if let Some(last) = entry.last_delegation { + row["lastDelegation"] = serde_json::json!(last); + } + Some(row) + }) + .collect(); + + let reply = serde_json::to_string(&rows) + .map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("read work-state ({} agent rows)", rows.len()), + reply: Some(reply), + }) + } + + /// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current + /// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`), + /// so an agent only ever writes its own row. Reuses the LS3 write provider + /// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and + /// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`). + /// **ACK only** (no inline reply). + #[allow(clippy::too_many_arguments)] + async fn set_workstate( + &self, + project: &Project, + agent: AgentId, + status: domain::live_state::WorkStatus, + intent: Option, + progress: Option, + ticket: Option, + last_delegation: Option, + ) -> Result { + let provider = self + .live_state + .as_deref() + .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; + let update = provider + .live_state_for(&project.root) + .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; + update + .execute(UpdateLiveStateInput { + agent_id: agent, + ticket, + intent: intent.unwrap_or_default(), + status, + progress, + last_delegation, + }) + .await?; + Ok(OrchestratorOutcome { + detail: format!("set work-state for agent {agent}"), + reply: None, + }) + } + /// `memory.write` → writes a note under an exclusive write-lease. async fn write_memory( &self, diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 92513e0..0ce5897 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -3188,3 +3188,215 @@ async fn resolved_deny_posture_reflected_as_plan_mode() { "project root flowed into the projection ctx" ); } + +// --------------------------------------------------------------------------- +// État du projet injection at launch (lot LS4) — `resolve_live_state` owns the +// manifest join + self-exclusion + ordering + cap; the composer renders it. +// --------------------------------------------------------------------------- + +/// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the +/// live-state preview injected into the convention file at launch. +struct SeededLiveStore { + state: Mutex, +} +#[async_trait] +impl domain::ports::LiveStateStore for SeededLiveStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert( + &self, + entry: domain::live_state::LiveEntry, + ) -> Result<(), domain::ports::StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune( + &self, + now_ms: u64, + ttl_ms: u64, + max_n: usize, + ) -> Result<(), domain::ports::StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } +} + +/// Fixed clock so the seeded rows are never pruned for age at read time. +struct InjectClock(i64); +impl domain::ports::Clock for InjectClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +/// A [`LiveStateLeanProvider`] yielding the same [`GetLiveStateLean`] over a shared +/// seeded store (root-agnostic for the test). +struct SeededLeanProvider { + getter: Arc, +} +impl application::LiveStateLeanProvider for SeededLeanProvider { + fn live_state_lean_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.getter)) + } +} + +#[tokio::test] +async fn launch_injects_live_state_excluding_self_capped_in_manifest_order() { + use domain::live_state::{LiveEntry, LiveState, WorkStatus}; + + let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); + let plan = Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }); + + // The launched agent (self) — must be EXCLUDED from its own preview. + let me = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&me, "# ctx body"); + + // 18 OTHER agents in the manifest, in a deterministic order (aid 2..=19). + { + let mut inner = contexts.0.lock().unwrap(); + for n in 2..=19u128 { + let other = scratch_agent( + aid(n), + &format!("A{n:02}"), + &format!("agents/a{n}.md"), + pid(9), + ); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(&other)); + } + } + + // Seed the live-state store: a row for self (must not surface), a row for each + // of the 18 manifest peers, plus one OFF-manifest row (aid 99) that must drop. + let now = 1_000_000_i64; + let mut state = LiveState::default(); + state.upsert( + LiveEntry::new( + aid(1), + None, + "my own work", + WorkStatus::Working, + None, + None, + now as u64, + ) + .unwrap(), + ); + for n in 2..=19u128 { + state.upsert( + LiveEntry::new( + aid(n), + None, + format!("intent {n}"), + WorkStatus::Working, + None, + None, + now as u64, + ) + .unwrap(), + ); + } + state.upsert( + LiveEntry::new( + aid(99), + None, + "ghost", + WorkStatus::Idle, + None, + None, + now as u64, + ) + .unwrap(), + ); + + let store = Arc::new(SeededLiveStore { + state: Mutex::new(state), + }); + let getter = Arc::new(application::GetLiveStateLean::new( + store as Arc, + Arc::new(InjectClock(now)), + )); + let provider = Arc::new(SeededLeanProvider { getter }); + + let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); + let tr = trace(); + let fs = FakeFs::new(Arc::clone(&tr)); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills::default()), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), + None, + ) + .with_live_state_lean(provider as Arc); + + launch.execute(launch_input(me.id)).await.expect("launch"); + + let writes = fs.context_writes(); + assert_eq!(writes.len(), 1, "exactly one convention file written"); + let doc = String::from_utf8(writes[0].1.clone()).unwrap(); + + // The section is present. + assert!( + doc.contains("# État du projet"), + "live-state section present: {doc}" + ); + + // Self is EXCLUDED: the launched agent never sees its own row. + assert!( + !doc.contains("- **Backend**"), + "the launched agent must not see its own live row: {doc}" + ); + // The off-manifest row never surfaces (its name is unknown to the manifest). + assert!( + !doc.contains("ghost"), + "off-manifest rows are dropped: {doc}" + ); + + // Cap: exactly LIVE_STATE_INJECT_MAX (16) peer rows are injected, in manifest + // order ⇒ A02..=A17 present, A18/A19 dropped by the cap. The only `- **` bullets + // in this composition are the live rows (no skills/memory bullets here). + assert_eq!( + doc.matches("- **").count(), + 16, + "capped to LIVE_STATE_INJECT_MAX peer rows: {doc}" + ); + assert!( + doc.contains("- **A02** — working · intent 2"), + "first peer row: {doc}" + ); + assert!( + doc.contains("- **A17** — working · intent 17"), + "16th peer row: {doc}" + ); + assert!( + !doc.contains("- **A18**"), + "18th agent dropped by the cap: {doc}" + ); + assert!( + !doc.contains("- **A19**"), + "19th agent dropped by the cap: {doc}" + ); + + // Manifest order preserved across the kept rows. + let a02 = doc.find("**A02**").unwrap(); + let a17 = doc.find("**A17**").unwrap(); + assert!(a02 < a17, "rows follow manifest order: {doc}"); +} diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 92ae633..1b32add 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -39,9 +39,9 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ - CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent, - ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext, - UpdateLiveState, + CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn, + LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService, + TerminalSessions, UpdateAgentContext, UpdateLiveState, }; use domain::live_state::{LiveEntry, LiveState, WorkStatus}; use domain::ports::LiveStateStore; @@ -493,6 +493,17 @@ impl LiveStateProvider for TestLiveStateProvider { } } +/// A [`LiveStateReadProvider`] (lot LS4, `idea_workstate_read`) yielding the same +/// [`GetLiveStateLean`] over a shared recording store (root-agnostic for the test). +struct TestLiveStateReadProvider { + getter: Arc, +} +impl LiveStateReadProvider for TestLiveStateReadProvider { + fn live_state_lean_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.getter)) + } +} + /// Fixed millis clock for deterministic `updated_at_ms`. struct FixedMillisClock(i64); impl Clock for FixedMillisClock { @@ -1297,6 +1308,14 @@ fn ask_fixture_full( Arc::new(FixedMillisClock(1_234)) as Arc, )), }) as Arc); + // LS4 read side over the SAME store, so `idea_workstate_set` then + // `idea_workstate_read` round-trips (no separate write provider would). + builder = builder.with_live_state_read(Arc::new(TestLiveStateReadProvider { + getter: Arc::new(GetLiveStateLean::new( + Arc::clone(&live) as Arc, + Arc::new(FixedMillisClock(1_234)) as Arc, + )), + }) as Arc); } let service = Arc::new(builder); @@ -1551,6 +1570,178 @@ async fn no_live_state_wired_means_no_write() { assert!(fx.live.entry_for(aid(1)).is_none()); } +// --------------------------------------------------------------------------- +// LS4 — `idea_workstate_set` / `idea_workstate_read` service round-trip. +// --------------------------------------------------------------------------- + +/// `idea_workstate_set` writes the current agent's row, then `idea_workstate_read` +/// returns it enriched with the **resolved display name** (via `ListAgents`), with the +/// declared status/intent — and **never** the `progress` field. +#[tokio::test] +async fn workstate_set_then_read_round_trips_with_resolved_name_and_no_progress() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // set — keyed on the handshake agent id; carries a progress note (must not leak). + let ack = fx + .service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status: WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("secret internal note".to_owned()), + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + // ACK only — no inline reply on the write path. + assert!(ack.reply.is_none(), "set is ACK only, no reply: {ack:?}"); + + // read — the JSON array carries one row for the architect. + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = + serde_json::from_str(out.reply.as_deref().expect("read carries a reply")).unwrap(); + let rows = json.as_array().expect("array"); + assert_eq!(rows.len(), 1, "one live row expected: {json}"); + let row = &rows[0]; + // Name resolved from the manifest (not the bare UUID). + assert_eq!(row["agent"], serde_json::json!("architect")); + assert_eq!(row["status"], serde_json::json!("working")); + assert_eq!(row["intent"], serde_json::json!("ship LS4")); + // progress is NEVER exposed on read (the lean DTO has no such field). + assert!( + row.get("progress").is_none(), + "progress must never surface on read: {row}" + ); +} + +/// `idea_workstate_set` is last-writer-wins: a second set for the same agent replaces +/// the row in place (never a duplicate), and the read reflects the latest write. +#[tokio::test] +async fn workstate_set_is_last_writer_wins() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + for (status, intent) in [ + (WorkStatus::Working, "first"), + (WorkStatus::Blocked, "second"), + ] { + fx.service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status, + intent: Some(intent.to_owned()), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + } + + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap(); + let rows = json.as_array().unwrap(); + assert_eq!(rows.len(), 1, "LWW ⇒ exactly one row, no duplicate: {json}"); + assert_eq!(rows[0]["status"], serde_json::json!("blocked")); + assert_eq!(rows[0]["intent"], serde_json::json!("second")); +} + +/// `idea_workstate_read` drops rows whose agent left (or never was in) the manifest — +/// the manifest boundary is authoritative. +#[tokio::test] +async fn workstate_read_drops_off_manifest_rows() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // aid(1) is in the manifest; aid(2) is NOT. + for who in [aid(1), aid(2)] { + fx.service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: who, + status: WorkStatus::Working, + intent: Some("x".to_owned()), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + } + + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap(); + let rows = json.as_array().unwrap(); + assert_eq!(rows.len(), 1, "the off-manifest row is dropped: {json}"); + assert_eq!(rows[0]["agent"], serde_json::json!("architect")); +} + +/// An oversize `intent` (above the domain hard byte threshold) is **rejected** by the +/// write path (the domain bound surfaces as `AppError::Invalid`), never silently stored. +#[tokio::test] +async fn workstate_set_oversize_intent_is_rejected() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + let huge = "x".repeat(domain::live_state::FIELD_MAX_BYTES + 1); + let err = fx + .service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status: WorkStatus::Working, + intent: Some(huge), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect_err("oversize intent must be rejected"); + assert!( + matches!(err, application::AppError::Invalid(_)), + "oversize ⇒ AppError::Invalid, got {err:?}" + ); +} + /// The target returned to its prompt **without** `idea_reply`: the mediator's grace /// window expired and completed the turn "no reply". The awaiting ask must error out /// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang diff --git a/crates/domain/src/live_state.rs b/crates/domain/src/live_state.rs index 40d84ed..4d5e774 100644 --- a/crates/domain/src/live_state.rs +++ b/crates/domain/src/live_state.rs @@ -48,6 +48,39 @@ pub enum WorkStatus { Done, } +impl WorkStatus { + /// Parses a status label (case-insensitive) into a [`WorkStatus`]. + /// + /// Accepts exactly the five canonical labels (`idle`, `working`, `blocked`, + /// `waiting`, `done`), ignoring surrounding whitespace and ASCII case. Returns + /// `None` for anything else, so the caller can raise a typed error rather than + /// guess a default. + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "idle" => Some(Self::Idle), + "working" => Some(Self::Working), + "blocked" => Some(Self::Blocked), + "waiting" => Some(Self::Waiting), + "done" => Some(Self::Done), + _ => None, + } + } + + /// The canonical, human-readable label of this status (the inverse of + /// [`WorkStatus::parse`]): `idle`, `working`, `blocked`, `waiting` or `done`. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Working => "working", + Self::Blocked => "blocked", + Self::Waiting => "waiting", + Self::Done => "done", + } + } +} + /// A single agent's live-state row. One per [`AgentId`] in a [`LiveState`]. /// /// Construct via [`LiveEntry::new`], which enforces the field invariants @@ -180,6 +213,30 @@ mod tests { TicketId::from_uuid(uuid::Uuid::from_u128(n)) } + #[test] + fn work_status_parse_is_case_insensitive_and_rejects_unknown() { + assert_eq!(WorkStatus::parse("idle"), Some(WorkStatus::Idle)); + assert_eq!(WorkStatus::parse(" Working "), Some(WorkStatus::Working)); + assert_eq!(WorkStatus::parse("BLOCKED"), Some(WorkStatus::Blocked)); + assert_eq!(WorkStatus::parse("waiting"), Some(WorkStatus::Waiting)); + assert_eq!(WorkStatus::parse("Done"), Some(WorkStatus::Done)); + assert_eq!(WorkStatus::parse("busy"), None); + assert_eq!(WorkStatus::parse(""), None); + } + + #[test] + fn work_status_label_round_trips_through_parse() { + for status in [ + WorkStatus::Idle, + WorkStatus::Working, + WorkStatus::Blocked, + WorkStatus::Waiting, + WorkStatus::Done, + ] { + assert_eq!(WorkStatus::parse(status.label()), Some(status)); + } + } + #[test] fn new_truncates_intent_and_progress_at_soft_bound() { let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50); diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index a772dbd..b1afc58 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::conversation::ConversationParty; use crate::ids::{AgentId, NodeId}; +use crate::live_state::WorkStatus; use crate::mailbox::TicketId; use crate::skill::SkillScope; @@ -56,6 +57,15 @@ pub enum OrchestratorError { /// The offending id value. value: String, }, + /// The `status` field of `workstate.set` is present but not a recognised + /// [`WorkStatus`] label. + #[error("unknown work status `{status}` for action `{action}`")] + UnknownWorkStatus { + /// The action being validated. + action: String, + /// The offending status value. + status: String, + }, } /// The raw, wire-level orchestrator request as deserialised from a request file. @@ -132,6 +142,23 @@ pub struct OrchestratorRequest { /// the aggregated index). Ignored by the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub slug: Option, + /// Live-state status label for `workstate.set` (`idle|working|blocked|waiting|done`, + /// case-insensitive). Required by that action, ignored otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Short current-intent free-text for `workstate.set` (domain-bounded downstream). + /// Optional, ignored by the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + /// Optional finer-grained progress note for `workstate.set` (domain-bounded + /// downstream). Ignored by the other actions. **Never** exposed on read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Optional `lastDelegation` ticket id for `workstate.set` (the most recent + /// delegation the agent issued). Blank ⇒ none; non-uuid ⇒ rejected. Ignored by + /// the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_delegation: Option, } /// A validated orchestrator command — the only thing the application layer acts on. @@ -269,6 +296,33 @@ pub enum OrchestratorCommand { /// The writing party (handshake identity). requester: ConversationParty, }, + /// Read the project's agent **live-state** (`idea_workstate_read`, programme + /// live-state, lot LS4): the lean "who is doing what right now" snapshot, enriched + /// with each agent's display name. Read-only; `progress` is never exposed. + ReadWorkState { + /// The party that issued the read (handshake identity). Carried for symmetry + /// with the other read tools; the read is project-wide (no per-requester + /// filtering). + requester: ConversationParty, + }, + /// Publish (insert or replace) the **current agent's** live-state row + /// (`idea_workstate_set`, lot LS4). The key is the handshake `requestedBy` + /// identity — an agent only ever writes its own row (no agent argument). + SetWorkState { + /// The agent whose row is written (the handshake identity, keyed + /// last-writer-wins). Never a model-managed value. + agent: AgentId, + /// Coarse status the agent declares. + status: WorkStatus, + /// Short current-intent free-text (domain-bounded on write); `None` ⇒ cleared. + intent: Option, + /// Optional finer-grained progress note (domain-bounded on write). + progress: Option, + /// The ticket the agent is currently handling, if any. + ticket: Option, + /// The ticket of the most recent delegation it issued, if any. + last_delegation: Option, + }, } /// Where IdeA should place a launched agent session. @@ -365,6 +419,20 @@ impl OrchestratorRequest { content: self.require("content", action, self.content.as_deref())?, requester: self.requester_party(), }), + "workstate.read" => Ok(OrchestratorCommand::ReadWorkState { + requester: self.requester_party(), + }), + "workstate.set" => Ok(OrchestratorCommand::SetWorkState { + // The key is the handshake identity (`requestedBy`), exactly like + // `agent.reply`: an agent writes only its own row, never a peer's. + agent: self.require_from(action)?, + status: self.require_work_status(action)?, + intent: self.intent.clone(), + progress: self.progress.clone(), + ticket: self.optional_ticket(action)?, + last_delegation: self + .parse_optional_ticket(action, self.last_delegation.as_deref())?, + }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, @@ -454,17 +522,40 @@ impl OrchestratorRequest { /// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒ /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). fn optional_ticket(&self, action: &str) -> Result, OrchestratorError> { - match self.ticket.as_deref().map(str::trim) { + self.parse_optional_ticket(action, self.ticket.as_deref()) + } + + /// Parses an **optional** ticket-id field (`ticket`, `lastDelegation`) into a + /// [`TicketId`]. Absent/blank ⇒ `None`; present-but-non-uuid ⇒ + /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). + #[allow(clippy::unused_self)] + fn parse_optional_ticket( + &self, + action: &str, + raw: Option<&str>, + ) -> Result, OrchestratorError> { + match raw.map(str::trim) { None | Some("") => Ok(None), - Some(raw) => uuid::Uuid::parse_str(raw) + Some(value) => uuid::Uuid::parse_str(value) .map(|u| Some(TicketId::from_uuid(u))) .map_err(|_| OrchestratorError::InvalidAgentId { action: action.to_owned(), - value: raw.to_owned(), + value: value.to_owned(), }), } } + /// Parses the **required** `status` of `workstate.set` into a [`WorkStatus`]. + /// Missing/blank ⇒ [`OrchestratorError::MissingField`]; present-but-unknown ⇒ + /// [`OrchestratorError::UnknownWorkStatus`]. + fn require_work_status(&self, action: &str) -> Result { + let raw = self.require("status", action, self.status.as_deref())?; + WorkStatus::parse(&raw).ok_or_else(|| OrchestratorError::UnknownWorkStatus { + action: action.to_owned(), + status: raw, + }) + } + /// The optional target agent name for the FileGuard context tools: `targetAgent` /// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context). fn optional_target_agent(&self) -> Option { @@ -939,6 +1030,115 @@ mod tests { ); } + #[test] + fn workstate_read_derives_requester_party() { + let uid = uuid::Uuid::from_u128(5); + let r = req(&format!( + r#"{{ "type":"workstate.read", "requestedBy":"{uid}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::agent(AgentId::from_uuid(uid)), + } + ); + // No requester ⇒ user party (still valid: read is project-wide). + assert_eq!( + req(r#"{ "type":"workstate.read" }"#).validate().unwrap(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + } + ); + } + + #[test] + fn workstate_set_keys_on_requester_and_parses_fields() { + let from = uuid::Uuid::from_u128(3); + let tkt = uuid::Uuid::from_u128(9); + let last = uuid::Uuid::from_u128(11); + let r = req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"Working", + "intent":"ship LS4", "progress":"wiring", "ticket":"{tkt}", + "lastDelegation":"{last}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::SetWorkState { + agent: AgentId::from_uuid(from), + status: WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("wiring".to_owned()), + ticket: Some(TicketId::from_uuid(tkt)), + last_delegation: Some(TicketId::from_uuid(last)), + } + ); + } + + #[test] + fn workstate_set_requires_status_and_requester() { + let from = uuid::Uuid::from_u128(3); + // Missing status ⇒ MissingField. + assert_eq!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}" }}"# + )) + .validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "status".to_owned(), + }) + ); + // Unknown status ⇒ UnknownWorkStatus. + assert_eq!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"busy" }}"# + )) + .validate(), + Err(OrchestratorError::UnknownWorkStatus { + action: "workstate.set".to_owned(), + status: "busy".to_owned(), + }) + ); + // Missing requester ⇒ MissingField on `requestedBy`. + assert_eq!( + req(r#"{ "type":"workstate.set", "status":"idle" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "requestedBy".to_owned(), + }) + ); + // Non-uuid lastDelegation ⇒ InvalidAgentId (generic invalid-id error). + assert!(matches!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "lastDelegation":"nope" }}"# + )) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Non-uuid `ticket` ⇒ InvalidAgentId too (same parse_optional_ticket path). + assert!(matches!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "ticket":"nope" }}"# + )) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Non-uuid `requestedBy` ⇒ InvalidAgentId (the handshake key must be a real id). + assert!(matches!( + req(r#"{ "type":"workstate.set", "requestedBy":"not-a-uuid", "status":"idle" }"#) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Blank `requestedBy` ⇒ MissingField (trimmed-empty is treated as absent). + assert_eq!( + req(r#"{ "type":"workstate.set", "requestedBy":" ", "status":"idle" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "requestedBy".to_owned(), + }) + ); + } + #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index e7fdc6d..8b1fb74 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -56,6 +56,7 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_context_read" | "idea_memory_read" | "idea_skill_read" + | "idea_workstate_read" ) } @@ -220,6 +221,37 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_workstate_read", + description: "Read what every IdeA agent is doing right now (the project live-state). \ + Returns a JSON array, one row per agent: name, status \ + (idle|working|blocked|waiting|done), short intent, and optional \ + current/last-delegation ticket. Last-writer-wins, not real-time.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_workstate_set", + description: "Publish your own current status to the project live-state so other \ + agents can coordinate. Writes only YOUR row (keyed on your identity). \ + `status` is required; `intent`/`progress`/`ticket`/`lastDelegation` are \ + optional. Acknowledged only (no reply).", + input_schema: json!({ + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["idle", "working", "blocked", "waiting", "done"], "description": "Your coarse status right now." }, + "intent": { "type": "string", "description": "Short description of what you are doing." }, + "progress": { "type": "string", "description": "Optional finer-grained progress note." }, + "ticket": { "type": "string", "description": "Optional id of the ticket you are currently handling." }, + "lastDelegation": { "type": "string", "description": "Optional id of the most recent delegation you issued." } + }, + "required": ["status"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_create_skill", description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", @@ -347,6 +379,25 @@ pub fn map_tool_call( name: s("name"), ..base() }, + "idea_workstate_read" => OrchestratorRequest { + request_type: Some("workstate.read".to_owned()), + // The requester (handshake identity) is carried for symmetry with the + // other read tools; the read is project-wide. + requested_by: Some(requester.to_owned()), + ..base() + }, + "idea_workstate_set" => OrchestratorRequest { + request_type: Some("workstate.set".to_owned()), + // The written key is the connected peer's handshake identity (never a tool + // argument): injected as `requestedBy` so `validate` keys the row on it. + requested_by: Some(requester.to_owned()), + status: s("status"), + intent: s("intent"), + progress: s("progress"), + ticket: s("ticket"), + last_delegation: s("lastDelegation"), + ..base() + }, "idea_create_skill" => OrchestratorRequest { request_type: Some("skill.create".to_owned()), name: s("name"), @@ -405,6 +456,10 @@ fn base() -> OrchestratorRequest { ticket: None, content: None, slug: None, + status: None, + intent: None, + progress: None, + last_delegation: None, } } @@ -670,4 +725,90 @@ mod tests { // ACK only: the result is routed to the awaiting requester, not echoed. assert!(!tool_returns_reply("idea_reply")); } + + #[test] + fn workstate_read_maps_with_requester_party() { + let cmd = map_tool_call("idea_workstate_read", &json!({}), REQ).unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::ReadWorkState { + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + // Read carries an inline reply (the live-state JSON array); set does not. + assert!(tool_returns_reply("idea_workstate_read")); + assert!(!tool_returns_reply("idea_workstate_set")); + } + + #[test] + fn workstate_set_keys_on_handshake_requester() { + let tkt = "22222222-2222-2222-2222-222222222222"; + let cmd = map_tool_call( + "idea_workstate_set", + &json!({ "status": "working", "intent": "ship", "ticket": tkt }), + REQ, + ) + .unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::SetWorkState { + agent: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), + status: domain::live_state::WorkStatus::Working, + intent: Some("ship".to_owned()), + progress: None, + ticket: Some(domain::mailbox::TicketId::from_uuid( + uuid::Uuid::parse_str(tkt).unwrap() + )), + last_delegation: None, + } + ); + // Missing status ⇒ validation error. + let err = map_tool_call("idea_workstate_set", &json!({ "intent": "x" }), REQ); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + // Status outside the enum ⇒ validation error too (UnknownWorkStatus surfaces + // as the generic Invalid map error). + let bad = map_tool_call("idea_workstate_set", &json!({ "status": "busy" }), REQ); + assert!( + matches!(bad, Err(ToolMapError::Invalid(_))), + "an out-of-enum status must be rejected, got {bad:?}" + ); + } + + /// The catalogue advertises the two live-state tools (lot LS4) with schemas + /// matching the frozen contract: `set` requires `status` constrained to the exact + /// five-label enum; `read` takes no parameter. + #[test] + fn catalogue_advertises_workstate_tools_with_conformant_schemas() { + let cat = catalogue(); + let by_name = |name: &str| { + cat.iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("catalogue must contain {name}")) + }; + + // read — object schema, no declared properties, no required fields. + let read = by_name("idea_workstate_read"); + assert_eq!(read.input_schema["type"], json!("object")); + assert_eq!( + read.input_schema["properties"], + json!({}), + "read takes no parameter" + ); + assert!( + read.input_schema.get("required").is_none(), + "read requires nothing" + ); + + // set — `status` required, enum exactly the five canonical labels. + let set = by_name("idea_workstate_set"); + assert_eq!(set.input_schema["type"], json!("object")); + assert_eq!(set.input_schema["required"], json!(["status"])); + assert_eq!( + set.input_schema["properties"]["status"]["enum"], + json!(["idle", "working", "blocked", "waiting", "done"]), + "status enum must match WorkStatus labels exactly" + ); + } } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 0da2353..a98d560 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -513,6 +513,9 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { "idea_context_propose", "idea_memory_read", "idea_memory_write", + // Live-state tools (programme live-state, lot LS4). + "idea_workstate_read", + "idea_workstate_set", ] { assert!( names.contains(&expected), @@ -521,8 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { } assert_eq!( tools.len(), - 12, - "exactly the twelve idea_* tools (7 base + idea_skill_read + 4 FileGuard C7); got {names:?}" + 14, + "exactly the fourteen idea_* tools (7 base + idea_skill_read + 4 FileGuard C7 + 2 live-state LS4); got {names:?}" ); // Every tool advertises an object input schema.