feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)
Clôt le cold-start de coordination du programme live-state : - domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate. - application : section bornée « # État du projet » injectée au lancement (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ; LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch. - infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping, tool_returns_reply), compteur d'outils MCP 12→14. - app-tauri : providers câblés, garde du nombre d'outils 12→14. - tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service, infrastructure mcp_server, app-tauri state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -18,21 +18,22 @@ use application::{
|
|||||||
CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||||
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout,
|
FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState,
|
||||||
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
|
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||||
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
|
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||||
ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
|
LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
|
||||||
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
|
||||||
LiveStateProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||||
OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
|
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
|
||||||
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory,
|
||||||
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
|
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
|
||||||
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent,
|
ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
|
||||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions,
|
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions,
|
||||||
UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
|
||||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, UpdateMemory,
|
||||||
WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory,
|
||||||
|
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
|
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** (`<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<dyn Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppLiveStateLeanProvider {
|
||||||
|
fn getter(&self, root: &domain::project::ProjectPath) -> Arc<GetLiveStateLean> {
|
||||||
|
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<Arc<GetLiveStateLean>> {
|
||||||
|
Some(self.getter(root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveStateReadProvider for AppLiveStateLeanProvider {
|
||||||
|
fn live_state_lean_for(
|
||||||
|
&self,
|
||||||
|
root: &domain::project::ProjectPath,
|
||||||
|
) -> Option<Arc<GetLiveStateLean>> {
|
||||||
|
Some(self.getter(root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
|
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
|
||||||
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
|
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
|
||||||
/// lancement en cours.
|
/// lancement en cours.
|
||||||
@ -856,7 +895,13 @@ impl AppState {
|
|||||||
// Projection des permissions au (re)lancement (lot LP3-5) : avec le
|
// Projection des permissions au (re)lancement (lot LP3-5) : avec le
|
||||||
// permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur
|
// 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.
|
// 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<dyn Clock>,
|
||||||
|
}) as Arc<dyn LiveStateLeanProvider>),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
|
||||||
@ -1227,7 +1272,13 @@ impl AppState {
|
|||||||
// délégation.
|
// délégation.
|
||||||
.with_live_state(Arc::new(AppLiveStateProvider {
|
.with_live_state(Arc::new(AppLiveStateProvider {
|
||||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
}) as Arc<dyn LiveStateProvider>),
|
}) as Arc<dyn LiveStateProvider>)
|
||||||
|
// 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<dyn Clock>,
|
||||||
|
}) as Arc<dyn LiveStateReadProvider>),
|
||||||
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici.
|
// 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
|
// 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
|
// 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",
|
"idea_memory_write",
|
||||||
// Skill-awareness : lecture à la demande du corps d'un skill.
|
// Skill-awareness : lecture à la demande du corps d'un skill.
|
||||||
"idea_skill_read",
|
"idea_skill_read",
|
||||||
|
// Live-state (programme live-state, lot LS4).
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_workstate_set",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -3091,8 +3145,8 @@ mod mcp_serve_peer_tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
12,
|
14,
|
||||||
"exactly the twelve idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read); got {names:?}"
|
"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
|
drop(client); // EOF ⇒ serve loop ends
|
||||||
@ -3529,6 +3583,133 @@ mod mcp_serve_peer_tests {
|
|||||||
ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n)))
|
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<domain::live_state::LiveState>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::LiveStateStore for InMemLiveStore {
|
||||||
|
async fn load(&self) -> Result<domain::live_state::LiveState, StoreError> {
|
||||||
|
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<application::UpdateLiveState>,
|
||||||
|
}
|
||||||
|
impl application::LiveStateProvider for WsWriteProvider {
|
||||||
|
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<application::UpdateLiveState>> {
|
||||||
|
Some(Arc::clone(&self.update))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct WsReadProvider {
|
||||||
|
getter: Arc<application::GetLiveStateLean>,
|
||||||
|
}
|
||||||
|
impl application::LiveStateReadProvider for WsReadProvider {
|
||||||
|
fn live_state_lean_for(
|
||||||
|
&self,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
) -> Option<Arc<application::GetLiveStateLean>> {
|
||||||
|
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<OrchestratorService> {
|
||||||
|
let store = Arc::new(InMemLiveStore::default()) as Arc<dyn domain::ports::LiveStateStore>;
|
||||||
|
let clock = Arc::new(FixedClock) as Arc<dyn Clock>;
|
||||||
|
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<dyn application::LiveStateProvider>)
|
||||||
|
.with_live_state_read(read as Arc<dyn application::LiveStateReadProvider>);
|
||||||
|
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
|
/// WIRING — with `.with_context_guard(...)`, `memory.write` then
|
||||||
/// `memory.read` round-trips the content instead of erroring "not
|
/// `memory.read` round-trips the content instead of erroring "not
|
||||||
/// configured". Proves the four use cases reached the dispatch.
|
/// configured". Proves the four use cases reached the dispatch.
|
||||||
|
|||||||
@ -30,10 +30,13 @@ use domain::{
|
|||||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use domain::live_state::WorkStatus;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
use crate::project::project_context_path;
|
use crate::project::project_context_path;
|
||||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||||
|
use crate::workstate::GetLiveStateLean;
|
||||||
|
|
||||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||||
const AGENTS_SUBDIR: &str = "agents";
|
const AGENTS_SUBDIR: &str = "agents";
|
||||||
@ -85,6 +88,45 @@ pub trait ProviderSessionProvider: Send + Sync {
|
|||||||
) -> Option<Arc<dyn ProviderSessionStore>>;
|
) -> Option<Arc<dyn ProviderSessionStore>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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**
|
||||||
|
/// (`<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<Arc<GetLiveStateLean>>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CreateAgentFromScratch
|
// CreateAgentFromScratch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1080,6 +1122,12 @@ pub struct LaunchAgent {
|
|||||||
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
|
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
|
||||||
/// (comportement historique préservé pour les call sites/tests legacy).
|
/// (comportement historique préservé pour les call sites/tests legacy).
|
||||||
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
||||||
|
/// 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<Arc<dyn LiveStateLeanProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LaunchAgent {
|
impl LaunchAgent {
|
||||||
@ -1117,9 +1165,20 @@ impl LaunchAgent {
|
|||||||
handoffs: None,
|
handoffs: None,
|
||||||
provider_sessions: None,
|
provider_sessions: None,
|
||||||
projectors: 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<dyn LiveStateLeanProvider>) -> Self {
|
||||||
|
self.live_state_lean = Some(provider);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
|
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
|
||||||
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
|
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
|
||||||
/// concrete permission config. Without this call (legacy call sites / tests),
|
/// concrete permission config. Without this call (legacy call sites / tests),
|
||||||
@ -1262,6 +1321,58 @@ impl LaunchAgent {
|
|||||||
store.load(conversation).await.ok().flatten()
|
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<InjectedLiveRow> {
|
||||||
|
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<AgentId, &crate::workstate::LeanLiveEntry> =
|
||||||
|
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`.
|
/// Reads the shared project context from `.ideai/CONTEXT.md`.
|
||||||
///
|
///
|
||||||
/// A missing file is normal for existing projects and simply omits the
|
/// A missing file is normal for existing projects and simply omits the
|
||||||
@ -1472,6 +1583,12 @@ impl LaunchAgent {
|
|||||||
let handoff = self
|
let handoff = self
|
||||||
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
|
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
|
||||||
.await;
|
.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
|
// 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
|
// 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 —
|
// semantic embedder would now help. Fully isolated from the launch outcome —
|
||||||
@ -1492,6 +1609,7 @@ impl LaunchAgent {
|
|||||||
&skills,
|
&skills,
|
||||||
&memory,
|
&memory,
|
||||||
handoff.as_ref(),
|
handoff.as_ref(),
|
||||||
|
&live_rows,
|
||||||
profile.mcp.is_some(),
|
profile.mcp.is_some(),
|
||||||
&mut spec,
|
&mut spec,
|
||||||
)
|
)
|
||||||
@ -1970,6 +2088,7 @@ impl LaunchAgent {
|
|||||||
skills: &[Skill],
|
skills: &[Skill],
|
||||||
memory: &[MemoryIndexEntry],
|
memory: &[MemoryIndexEntry],
|
||||||
handoff: Option<&Handoff>,
|
handoff: Option<&Handoff>,
|
||||||
|
live_rows: &[InjectedLiveRow],
|
||||||
mcp_enabled: bool,
|
mcp_enabled: bool,
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@ -1989,6 +2108,7 @@ impl LaunchAgent {
|
|||||||
skills,
|
skills,
|
||||||
memory,
|
memory,
|
||||||
handoff,
|
handoff,
|
||||||
|
live_rows,
|
||||||
mcp_enabled,
|
mcp_enabled,
|
||||||
);
|
);
|
||||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||||
@ -2589,6 +2709,7 @@ pub(crate) fn compose_convention_file(
|
|||||||
skills: &[Skill],
|
skills: &[Skill],
|
||||||
memory: &[MemoryIndexEntry],
|
memory: &[MemoryIndexEntry],
|
||||||
handoff: Option<&Handoff>,
|
handoff: Option<&Handoff>,
|
||||||
|
live_rows: &[InjectedLiveRow],
|
||||||
mcp_enabled: bool,
|
mcp_enabled: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = String::new();
|
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
|
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
|
||||||
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
||||||
if let Some(handoff) = handoff {
|
if let Some(handoff) = handoff {
|
||||||
@ -2866,6 +3012,7 @@ mod tests {
|
|||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2896,6 +3043,7 @@ mod tests {
|
|||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2930,6 +3078,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2966,6 +3115,7 @@ mod tests {
|
|||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2984,8 +3134,16 @@ mod tests {
|
|||||||
// file-protocol modes, positioned right after the Skills awareness and
|
// file-protocol modes, positioned right after the Skills awareness and
|
||||||
// before the orchestration block's closing separator / project context.
|
// before the orchestration block's closing separator / project context.
|
||||||
for mcp_enabled in [true, false] {
|
for mcp_enabled in [true, false] {
|
||||||
let doc =
|
let doc = compose_convention_file(
|
||||||
compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled);
|
"/root",
|
||||||
|
"ctx",
|
||||||
|
"# Persona",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
mcp_enabled,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
doc.contains("**Mémoire IdeA**"),
|
doc.contains("**Mémoire IdeA**"),
|
||||||
"memory harvest directive present (mcp_enabled={mcp_enabled})"
|
"memory harvest directive present (mcp_enabled={mcp_enabled})"
|
||||||
@ -3009,14 +3167,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
|
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("idea_create_skill"));
|
||||||
assert!(
|
assert!(
|
||||||
!mcp_doc.contains("skill.create"),
|
!mcp_doc.contains("skill.create"),
|
||||||
"MCP awareness must point to the native IdeA tool"
|
"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("skill.create"));
|
||||||
assert!(
|
assert!(
|
||||||
!file_doc.contains("idea_create_skill"),
|
!file_doc.contains("idea_create_skill"),
|
||||||
@ -3051,6 +3210,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
true, // mcp_enabled
|
true, // mcp_enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -3084,7 +3244,7 @@ mod tests {
|
|||||||
// NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA »
|
// 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
|
// (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.
|
// 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"));
|
assert!(!doc.contains("# Skills disponibles"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3102,8 +3262,16 @@ mod tests {
|
|||||||
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
||||||
// An empty `memory` must yield exactly the previous document: no section,
|
// An empty `memory` must yield exactly the previous document: no section,
|
||||||
// byte-for-byte identical to the no-skills/no-memory composition.
|
// byte-for-byte identical to the no-skills/no-memory composition.
|
||||||
let with_empty =
|
let with_empty = compose_convention_file(
|
||||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false);
|
"/root",
|
||||||
|
"",
|
||||||
|
"# Persona\n\nDo X.",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!with_empty.contains("# Mémoire projet"),
|
!with_empty.contains("# Mémoire projet"),
|
||||||
"no memory ⇒ no memory section"
|
"no memory ⇒ no memory section"
|
||||||
@ -3112,7 +3280,16 @@ mod tests {
|
|||||||
// 4-arg call; this pins the omission contract explicitly).
|
// 4-arg call; this pins the omission contract explicitly).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
with_empty,
|
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,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -3171,6 +3349,7 @@ mod tests {
|
|||||||
std::slice::from_ref(&skill),
|
std::slice::from_ref(&skill),
|
||||||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||||||
None,
|
None,
|
||||||
|
&[],
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -3190,7 +3369,7 @@ mod tests {
|
|||||||
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
|
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
|
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
|
||||||
// the ban on the provider's native subagents (cadrage v3, Décision 3).
|
// 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.
|
// Native IdeA orchestration tools surfaced.
|
||||||
assert!(
|
assert!(
|
||||||
@ -3215,7 +3394,7 @@ mod tests {
|
|||||||
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
|
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
|
// 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.
|
// `[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!(
|
assert!(
|
||||||
doc.contains("idea_reply"),
|
doc.contains("idea_reply"),
|
||||||
"MCP prose must instruct answering via 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
|
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
|
||||||
// instruction (zero regression on the file path).
|
// 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"));
|
assert!(!file_doc.contains("idea_reply"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3234,7 +3414,7 @@ mod tests {
|
|||||||
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
|
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
|
||||||
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
|
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
|
||||||
// and crucially WITHOUT the `idea_*` tools (zero regression).
|
// 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!(
|
assert!(
|
||||||
doc.contains(".ideai/requests"),
|
doc.contains(".ideai/requests"),
|
||||||
@ -3257,7 +3437,7 @@ mod tests {
|
|||||||
// est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire,
|
// 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
|
// contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand
|
||||||
// même être décrites, avec les noms des outils MCP correspondants.
|
// 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.
|
// Capacité contexte projet.
|
||||||
assert!(
|
assert!(
|
||||||
@ -3283,7 +3463,7 @@ mod tests {
|
|||||||
// appliquée directement. On asserte sur la nuance réellement écrite par le dev :
|
// 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
|
// « single-writer » + le fait qu'une proposition sans `target` est enregistrée
|
||||||
// « pour validation » (et donc « n'est PAS appliquée directement »).
|
// « 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!(
|
assert!(
|
||||||
doc.contains("single-writer"),
|
doc.contains("single-writer"),
|
||||||
@ -3303,7 +3483,7 @@ mod tests {
|
|||||||
fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() {
|
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
|
// 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).
|
// `.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.
|
// Capacités décrites par leurs fichiers/dossiers.
|
||||||
assert!(
|
assert!(
|
||||||
@ -3351,8 +3531,8 @@ mod tests {
|
|||||||
fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() {
|
fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() {
|
||||||
// Cloisonnement : la prose MCP ne mentionne pas le protocole fichier
|
// Cloisonnement : la prose MCP ne mentionne pas le protocole fichier
|
||||||
// `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil.
|
// `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil.
|
||||||
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||||
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!mcp.contains(".ideai/requests"),
|
!mcp.contains(".ideai/requests"),
|
||||||
@ -3382,7 +3562,7 @@ mod tests {
|
|||||||
fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() {
|
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.
|
// 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.
|
// 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
|
let mcp_brief_at = mcp
|
||||||
.find("idea_context_read")
|
.find("idea_context_read")
|
||||||
.expect("MCP capability brief present");
|
.expect("MCP capability brief present");
|
||||||
@ -3392,7 +3572,7 @@ mod tests {
|
|||||||
"MCP capability brief must come before the persona"
|
"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
|
let file_brief_at = file
|
||||||
.find(".ideai/CONTEXT.md")
|
.find(".ideai/CONTEXT.md")
|
||||||
.expect("file capability brief present");
|
.expect("file capability brief present");
|
||||||
@ -3478,7 +3658,8 @@ mod tests {
|
|||||||
MemoryType::Project,
|
MemoryType::Project,
|
||||||
)];
|
)];
|
||||||
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
|
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!(
|
assert!(
|
||||||
doc.contains("# Reprise de la conversation"),
|
doc.contains("# Reprise de la conversation"),
|
||||||
@ -3514,7 +3695,7 @@ mod tests {
|
|||||||
// objective = None ⇒ the section and the summary are present, but NO
|
// objective = None ⇒ the section and the summary are present, but NO
|
||||||
// `**Objectif :**` line.
|
// `**Objectif :**` line.
|
||||||
let h = handoff("Juste le résumé.", None);
|
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!(
|
assert!(
|
||||||
doc.contains("# Reprise de la conversation"),
|
doc.contains("# Reprise de la conversation"),
|
||||||
@ -3532,7 +3713,7 @@ mod tests {
|
|||||||
// objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
|
// objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
|
||||||
// line, but the section + summary still render.
|
// line, but the section + summary still render.
|
||||||
let h = handoff("Le résumé.", Some(" "));
|
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("# Reprise de la conversation"));
|
||||||
assert!(doc.contains("Le résumé."));
|
assert!(doc.contains("Le résumé."));
|
||||||
@ -3546,7 +3727,7 @@ mod tests {
|
|||||||
fn compose_convention_file_without_handoff_omits_resume_section() {
|
fn compose_convention_file_without_handoff_omits_resume_section() {
|
||||||
// handoff = None ⇒ no `# Reprise de la conversation` section at all
|
// handoff = None ⇒ no `# Reprise de la conversation` section at all
|
||||||
// (zero regression for launches with no resume).
|
// (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!(
|
assert!(
|
||||||
!doc.contains("# Reprise de la conversation"),
|
!doc.contains("# Reprise de la conversation"),
|
||||||
"no handoff ⇒ no resume section: {doc}"
|
"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 —
|
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
|
||||||
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
|
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
|
||||||
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
|
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
|
||||||
|
|||||||
@ -29,10 +29,11 @@ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversa
|
|||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
|
||||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
|
||||||
};
|
};
|
||||||
pub use resume::{
|
pub use resume::{
|
||||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||||
|
|||||||
@ -37,15 +37,15 @@ pub use agent::{
|
|||||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||||
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
||||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
||||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
||||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||||
RESUME_PROMPT,
|
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use conversation::RecordTurn;
|
pub use conversation::RecordTurn;
|
||||||
pub use embedder::{
|
pub use embedder::{
|
||||||
@ -81,8 +81,9 @@ pub use memory::{
|
|||||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||||
};
|
};
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
|
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||||
OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
|
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
|
||||||
|
RecordTurnProvider, WriteMemory,
|
||||||
};
|
};
|
||||||
pub use permission::{
|
pub use permission::{
|
||||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||||
|
|||||||
@ -12,6 +12,6 @@ pub use context_guard::{
|
|||||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||||
};
|
};
|
||||||
pub use service::{
|
pub use service::{
|
||||||
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
|
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||||
OrchestratorService, RecordTurnProvider,
|
OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -45,7 +45,7 @@ use crate::orchestrator::{
|
|||||||
};
|
};
|
||||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
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
|
/// 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
|
/// 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<Arc<UpdateLiveState>>;
|
fn live_state_for(&self, root: &ProjectPath) -> Option<Arc<UpdateLiveState>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Arc<GetLiveStateLean>>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
|
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
|
||||||
pub struct OrchestratorService {
|
pub struct OrchestratorService {
|
||||||
create_agent: Arc<CreateAgentFromScratch>,
|
create_agent: Arc<CreateAgentFromScratch>,
|
||||||
@ -337,6 +354,12 @@ pub struct OrchestratorService {
|
|||||||
/// régression). **Best-effort strict** : un échec de persistance ne transforme
|
/// régression). **Best-effort strict** : un échec de persistance ne transforme
|
||||||
/// **jamais** un succès de délégation/reply en erreur.
|
/// **jamais** un succès de délégation/reply en erreur.
|
||||||
live_state: Option<Arc<dyn LiveStateProvider>>,
|
live_state: Option<Arc<dyn LiveStateProvider>>,
|
||||||
|
/// 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<Arc<dyn LiveStateReadProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
|
||||||
@ -403,6 +426,7 @@ impl OrchestratorService {
|
|||||||
memory_harvest: None,
|
memory_harvest: None,
|
||||||
read_skill: None,
|
read_skill: None,
|
||||||
live_state: None,
|
live_state: None,
|
||||||
|
live_state_read: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,6 +553,16 @@ impl OrchestratorService {
|
|||||||
self
|
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<dyn LiveStateReadProvider>) -> Self {
|
||||||
|
self.live_state_read = Some(provider);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Distille l'objectif d'un ticket en un `intent` court pour le live-state :
|
/// 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`]
|
/// 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
|
/// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne
|
||||||
@ -729,6 +763,28 @@ impl OrchestratorService {
|
|||||||
content,
|
content,
|
||||||
requester,
|
requester,
|
||||||
} => self.write_memory(project, slug, content, requester).await,
|
} => 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<OrchestratorOutcome, AppError> {
|
||||||
|
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<AgentId, String> =
|
||||||
|
listed.agents.into_iter().map(|a| (a.id, a.name)).collect();
|
||||||
|
|
||||||
|
let rows: Vec<serde_json::Value> = 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<String>,
|
||||||
|
progress: Option<String>,
|
||||||
|
ticket: Option<TicketId>,
|
||||||
|
last_delegation: Option<TicketId>,
|
||||||
|
) -> Result<OrchestratorOutcome, AppError> {
|
||||||
|
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.
|
/// `memory.write` → writes a note under an exclusive write-lease.
|
||||||
async fn write_memory(
|
async fn write_memory(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -3188,3 +3188,215 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
|
|||||||
"project root flowed into the projection ctx"
|
"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<domain::live_state::LiveState>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::LiveStateStore for SeededLiveStore {
|
||||||
|
async fn load(&self) -> Result<domain::live_state::LiveState, domain::ports::StoreError> {
|
||||||
|
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<application::GetLiveStateLean>,
|
||||||
|
}
|
||||||
|
impl application::LiveStateLeanProvider for SeededLeanProvider {
|
||||||
|
fn live_state_lean_for(
|
||||||
|
&self,
|
||||||
|
_root: &ProjectPath,
|
||||||
|
) -> Option<Arc<application::GetLiveStateLean>> {
|
||||||
|
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<dyn domain::ports::LiveStateStore>,
|
||||||
|
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<dyn application::LiveStateLeanProvider>);
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|||||||
@ -39,9 +39,9 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
|
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
|
||||||
ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext,
|
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
|
||||||
UpdateLiveState,
|
TerminalSessions, UpdateAgentContext, UpdateLiveState,
|
||||||
};
|
};
|
||||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
||||||
use domain::ports::LiveStateStore;
|
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<GetLiveStateLean>,
|
||||||
|
}
|
||||||
|
impl LiveStateReadProvider for TestLiveStateReadProvider {
|
||||||
|
fn live_state_lean_for(&self, _root: &ProjectPath) -> Option<Arc<GetLiveStateLean>> {
|
||||||
|
Some(Arc::clone(&self.getter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fixed millis clock for deterministic `updated_at_ms`.
|
/// Fixed millis clock for deterministic `updated_at_ms`.
|
||||||
struct FixedMillisClock(i64);
|
struct FixedMillisClock(i64);
|
||||||
impl Clock for FixedMillisClock {
|
impl Clock for FixedMillisClock {
|
||||||
@ -1297,6 +1308,14 @@ fn ask_fixture_full(
|
|||||||
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
|
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
|
||||||
)),
|
)),
|
||||||
}) as Arc<dyn LiveStateProvider>);
|
}) as Arc<dyn LiveStateProvider>);
|
||||||
|
// 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<dyn LiveStateStore>,
|
||||||
|
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
|
||||||
|
)),
|
||||||
|
}) as Arc<dyn LiveStateReadProvider>);
|
||||||
}
|
}
|
||||||
let service = Arc::new(builder);
|
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());
|
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
|
/// 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
|
/// window expired and completed the turn "no reply". The awaiting ask must error out
|
||||||
/// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang
|
/// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang
|
||||||
|
|||||||
@ -48,6 +48,39 @@ pub enum WorkStatus {
|
|||||||
Done,
|
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<Self> {
|
||||||
|
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`].
|
/// A single agent's live-state row. One per [`AgentId`] in a [`LiveState`].
|
||||||
///
|
///
|
||||||
/// Construct via [`LiveEntry::new`], which enforces the field invariants
|
/// Construct via [`LiveEntry::new`], which enforces the field invariants
|
||||||
@ -180,6 +213,30 @@ mod tests {
|
|||||||
TicketId::from_uuid(uuid::Uuid::from_u128(n))
|
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]
|
#[test]
|
||||||
fn new_truncates_intent_and_progress_at_soft_bound() {
|
fn new_truncates_intent_and_progress_at_soft_bound() {
|
||||||
let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50);
|
let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::ConversationParty;
|
||||||
use crate::ids::{AgentId, NodeId};
|
use crate::ids::{AgentId, NodeId};
|
||||||
|
use crate::live_state::WorkStatus;
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::skill::SkillScope;
|
use crate::skill::SkillScope;
|
||||||
|
|
||||||
@ -56,6 +57,15 @@ pub enum OrchestratorError {
|
|||||||
/// The offending id value.
|
/// The offending id value.
|
||||||
value: String,
|
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.
|
/// 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.
|
/// the aggregated index). Ignored by the other actions.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A validated orchestrator command — the only thing the application layer acts on.
|
/// A validated orchestrator command — the only thing the application layer acts on.
|
||||||
@ -269,6 +296,33 @@ pub enum OrchestratorCommand {
|
|||||||
/// The writing party (handshake identity).
|
/// The writing party (handshake identity).
|
||||||
requester: ConversationParty,
|
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<String>,
|
||||||
|
/// Optional finer-grained progress note (domain-bounded on write).
|
||||||
|
progress: Option<String>,
|
||||||
|
/// The ticket the agent is currently handling, if any.
|
||||||
|
ticket: Option<TicketId>,
|
||||||
|
/// The ticket of the most recent delegation it issued, if any.
|
||||||
|
last_delegation: Option<TicketId>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where IdeA should place a launched agent session.
|
/// Where IdeA should place a launched agent session.
|
||||||
@ -365,6 +419,20 @@ impl OrchestratorRequest {
|
|||||||
content: self.require("content", action, self.content.as_deref())?,
|
content: self.require("content", action, self.content.as_deref())?,
|
||||||
requester: self.requester_party(),
|
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),
|
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
|
||||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||||
name: self.require_name(action)?,
|
name: self.require_name(action)?,
|
||||||
@ -454,17 +522,40 @@ impl OrchestratorRequest {
|
|||||||
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
|
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
|
||||||
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
|
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
|
||||||
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, OrchestratorError> {
|
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, 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<Option<TicketId>, OrchestratorError> {
|
||||||
|
match raw.map(str::trim) {
|
||||||
None | Some("") => Ok(None),
|
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(|u| Some(TicketId::from_uuid(u)))
|
||||||
.map_err(|_| OrchestratorError::InvalidAgentId {
|
.map_err(|_| OrchestratorError::InvalidAgentId {
|
||||||
action: action.to_owned(),
|
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<WorkStatus, OrchestratorError> {
|
||||||
|
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`
|
/// The optional target agent name for the FileGuard context tools: `targetAgent`
|
||||||
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
|
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
|
||||||
fn optional_target_agent(&self) -> Option<String> {
|
fn optional_target_agent(&self) -> Option<String> {
|
||||||
@ -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]
|
#[test]
|
||||||
fn unknown_action_is_rejected() {
|
fn unknown_action_is_rejected() {
|
||||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||||
|
|||||||
@ -56,6 +56,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
|||||||
| "idea_context_read"
|
| "idea_context_read"
|
||||||
| "idea_memory_read"
|
| "idea_memory_read"
|
||||||
| "idea_skill_read"
|
| "idea_skill_read"
|
||||||
|
| "idea_workstate_read"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +221,37 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_create_skill",
|
name: "idea_create_skill",
|
||||||
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
|
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"),
|
name: s("name"),
|
||||||
..base()
|
..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 {
|
"idea_create_skill" => OrchestratorRequest {
|
||||||
request_type: Some("skill.create".to_owned()),
|
request_type: Some("skill.create".to_owned()),
|
||||||
name: s("name"),
|
name: s("name"),
|
||||||
@ -405,6 +456,10 @@ fn base() -> OrchestratorRequest {
|
|||||||
ticket: None,
|
ticket: None,
|
||||||
content: None,
|
content: None,
|
||||||
slug: 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.
|
// ACK only: the result is routed to the awaiting requester, not echoed.
|
||||||
assert!(!tool_returns_reply("idea_reply"));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -513,6 +513,9 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
|||||||
"idea_context_propose",
|
"idea_context_propose",
|
||||||
"idea_memory_read",
|
"idea_memory_read",
|
||||||
"idea_memory_write",
|
"idea_memory_write",
|
||||||
|
// Live-state tools (programme live-state, lot LS4).
|
||||||
|
"idea_workstate_read",
|
||||||
|
"idea_workstate_set",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
names.contains(&expected),
|
names.contains(&expected),
|
||||||
@ -521,8 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tools.len(),
|
tools.len(),
|
||||||
12,
|
14,
|
||||||
"exactly the twelve idea_* tools (7 base + idea_skill_read + 4 FileGuard C7); got {names:?}"
|
"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.
|
// Every tool advertises an object input schema.
|
||||||
|
|||||||
Reference in New Issue
Block a user