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:
@ -30,10 +30,13 @@ use domain::{
|
||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use domain::live_state::WorkStatus;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::{persist_doc, resolve_doc};
|
||||
use crate::project::project_context_path;
|
||||
use crate::terminal::{StructuredSessions, TerminalSessions};
|
||||
use crate::workstate::GetLiveStateLean;
|
||||
|
||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||
const AGENTS_SUBDIR: &str = "agents";
|
||||
@ -85,6 +88,45 @@ pub trait ProviderSessionProvider: Send + Sync {
|
||||
) -> Option<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1080,6 +1122,12 @@ pub struct LaunchAgent {
|
||||
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
|
||||
/// (comportement historique préservé pour les call sites/tests legacy).
|
||||
projectors: Option<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 {
|
||||
@ -1117,9 +1165,20 @@ impl LaunchAgent {
|
||||
handoffs: None,
|
||||
provider_sessions: None,
|
||||
projectors: None,
|
||||
live_state_lean: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
||||
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
||||
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
||||
/// n'est injectée — signature de [`Self::new`] **inchangée**. Builder additif.
|
||||
#[must_use]
|
||||
pub fn with_live_state_lean(mut self, provider: Arc<dyn LiveStateLeanProvider>) -> Self {
|
||||
self.live_state_lean = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
|
||||
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
|
||||
/// concrete permission config. Without this call (legacy call sites / tests),
|
||||
@ -1262,6 +1321,58 @@ impl LaunchAgent {
|
||||
store.load(conversation).await.ok().flatten()
|
||||
}
|
||||
|
||||
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
|
||||
/// injecter dans la section `# État du projet`, calqué sur [`Self::resolve_handoff`].
|
||||
///
|
||||
/// Ordre **du manifeste** (déterministe). L'agent lancé (`self_agent_id`) est
|
||||
/// **exclu** (il ne se voit pas lui-même). Les lignes du live-state qui ne
|
||||
/// correspondent à **aucun** agent du manifeste sont droppées (la frontière du
|
||||
/// manifeste prime). Le résultat est borné à [`LIVE_STATE_INJECT_MAX`].
|
||||
///
|
||||
/// **Jamais bloquant** : provider absent, erreur du store, ou parse en échec
|
||||
/// dégradent vers un `Vec` vide (section omise), exactement comme une mémoire/reprise
|
||||
/// absente — un lancement n'est jamais cassé par cet aperçu.
|
||||
async fn resolve_live_state(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
manifest: &AgentManifest,
|
||||
self_agent_id: AgentId,
|
||||
) -> Vec<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`.
|
||||
///
|
||||
/// A missing file is normal for existing projects and simply omits the
|
||||
@ -1472,6 +1583,12 @@ impl LaunchAgent {
|
||||
let handoff = self
|
||||
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
|
||||
.await;
|
||||
// Aperçu live-state des autres agents (lot LS4) : best-effort, additif. Injecté
|
||||
// comme section `# État du projet`. Ordre manifeste, self exclu, borné, vide ⇒
|
||||
// section omise. Indépendant du handoff/mémoire.
|
||||
let live_rows = self
|
||||
.resolve_live_state(&input.project.root, &manifest, agent.id)
|
||||
.await;
|
||||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
||||
// has just read the project memory, so this is the moment to check whether a
|
||||
// semantic embedder would now help. Fully isolated from the launch outcome —
|
||||
@ -1492,6 +1609,7 @@ impl LaunchAgent {
|
||||
&skills,
|
||||
&memory,
|
||||
handoff.as_ref(),
|
||||
&live_rows,
|
||||
profile.mcp.is_some(),
|
||||
&mut spec,
|
||||
)
|
||||
@ -1970,6 +2088,7 @@ impl LaunchAgent {
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
handoff: Option<&Handoff>,
|
||||
live_rows: &[InjectedLiveRow],
|
||||
mcp_enabled: bool,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
@ -1989,6 +2108,7 @@ impl LaunchAgent {
|
||||
skills,
|
||||
memory,
|
||||
handoff,
|
||||
live_rows,
|
||||
mcp_enabled,
|
||||
);
|
||||
let path = RemotePath::new(join(&spec.cwd, &target));
|
||||
@ -2589,6 +2709,7 @@ pub(crate) fn compose_convention_file(
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
handoff: Option<&Handoff>,
|
||||
live_rows: &[InjectedLiveRow],
|
||||
mcp_enabled: bool,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
@ -2742,6 +2863,31 @@ pub(crate) fn compose_convention_file(
|
||||
}
|
||||
}
|
||||
|
||||
// État du projet (lot LS4) : aperçu lean de ce que font les autres agents en ce
|
||||
// moment, placé APRÈS la mémoire projet et AVANT la reprise de conversation. Une
|
||||
// ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
|
||||
// d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
|
||||
// section entièrement omise (document octet-identique à sans-section).
|
||||
if !live_rows.is_empty() {
|
||||
out.push_str("\n\n---\n\n# État du projet\n\n");
|
||||
out.push_str(
|
||||
"Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \
|
||||
non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \
|
||||
`idea_workstate_read` / `idea_workstate_set`.\n\n",
|
||||
);
|
||||
for row in live_rows {
|
||||
out.push_str("- **");
|
||||
out.push_str(&row.name);
|
||||
out.push_str("** — ");
|
||||
out.push_str(row.status.label());
|
||||
if !row.intent.trim().is_empty() {
|
||||
out.push_str(" · ");
|
||||
out.push_str(row.intent.trim());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
|
||||
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
|
||||
if let Some(handoff) = handoff {
|
||||
@ -2866,6 +3012,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -2896,6 +3043,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -2930,6 +3078,7 @@ mod tests {
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -2966,6 +3115,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -2984,8 +3134,16 @@ mod tests {
|
||||
// file-protocol modes, positioned right after the Skills awareness and
|
||||
// before the orchestration block's closing separator / project context.
|
||||
for mcp_enabled in [true, false] {
|
||||
let doc =
|
||||
compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled);
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"ctx",
|
||||
"# Persona",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
mcp_enabled,
|
||||
);
|
||||
assert!(
|
||||
doc.contains("**Mémoire IdeA**"),
|
||||
"memory harvest directive present (mcp_enabled={mcp_enabled})"
|
||||
@ -3009,14 +3167,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
|
||||
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
assert!(mcp_doc.contains("idea_create_skill"));
|
||||
assert!(
|
||||
!mcp_doc.contains("skill.create"),
|
||||
"MCP awareness must point to the native IdeA tool"
|
||||
);
|
||||
|
||||
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let file_doc =
|
||||
compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
assert!(file_doc.contains("skill.create"));
|
||||
assert!(
|
||||
!file_doc.contains("idea_create_skill"),
|
||||
@ -3051,6 +3210,7 @@ mod tests {
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
true, // mcp_enabled
|
||||
);
|
||||
|
||||
@ -3084,7 +3244,7 @@ mod tests {
|
||||
// NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA »
|
||||
// (toujours présent), donc on n'asserte plus son absence — seulement celle de la
|
||||
// section d'affordances, qui, elle, dépend bien des skills assignés.
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
assert!(!doc.contains("# Skills disponibles"));
|
||||
}
|
||||
|
||||
@ -3102,8 +3262,16 @@ mod tests {
|
||||
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
||||
// An empty `memory` must yield exactly the previous document: no section,
|
||||
// byte-for-byte identical to the no-skills/no-memory composition.
|
||||
let with_empty =
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false);
|
||||
let with_empty = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona\n\nDo X.",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!with_empty.contains("# Mémoire projet"),
|
||||
"no memory ⇒ no memory section"
|
||||
@ -3112,7 +3280,16 @@ mod tests {
|
||||
// 4-arg call; this pins the omission contract explicitly).
|
||||
assert_eq!(
|
||||
with_empty,
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false)
|
||||
compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona\n\nDo X.",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -3133,6 +3310,7 @@ mod tests {
|
||||
),
|
||||
],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -3171,6 +3349,7 @@ mod tests {
|
||||
std::slice::from_ref(&skill),
|
||||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
@ -3190,7 +3369,7 @@ mod tests {
|
||||
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
|
||||
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
|
||||
// the ban on the provider's native subagents (cadrage v3, Décision 3).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
|
||||
// Native IdeA orchestration tools surfaced.
|
||||
assert!(
|
||||
@ -3215,7 +3394,7 @@ mod tests {
|
||||
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
|
||||
// B-5 — the solicited-agent side of the protocol: a delegated task arrives as
|
||||
// `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text.
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
assert!(
|
||||
doc.contains("idea_reply"),
|
||||
"MCP prose must instruct answering via idea_reply"
|
||||
@ -3226,7 +3405,8 @@ mod tests {
|
||||
);
|
||||
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
|
||||
// instruction (zero regression on the file path).
|
||||
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let file_doc =
|
||||
compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
assert!(!file_doc.contains("idea_reply"));
|
||||
}
|
||||
|
||||
@ -3234,7 +3414,7 @@ mod tests {
|
||||
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
|
||||
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
|
||||
// and crucially WITHOUT the `idea_*` tools (zero regression).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
|
||||
assert!(
|
||||
doc.contains(".ideai/requests"),
|
||||
@ -3257,7 +3437,7 @@ mod tests {
|
||||
// est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire,
|
||||
// contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand
|
||||
// même être décrites, avec les noms des outils MCP correspondants.
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
|
||||
// Capacité contexte projet.
|
||||
assert!(
|
||||
@ -3283,7 +3463,7 @@ mod tests {
|
||||
// appliquée directement. On asserte sur la nuance réellement écrite par le dev :
|
||||
// « single-writer » + le fait qu'une proposition sans `target` est enregistrée
|
||||
// « pour validation » (et donc « n'est PAS appliquée directement »).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
|
||||
assert!(
|
||||
doc.contains("single-writer"),
|
||||
@ -3303,7 +3483,7 @@ mod tests {
|
||||
fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() {
|
||||
// Surface SANS MCP : mêmes 3 capacités décrites UNIQUEMENT via les fichiers
|
||||
// `.ideai/` — et AUCUN nom d'outil `idea_*` (cloisonnement strict des surfaces).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
|
||||
// Capacités décrites par leurs fichiers/dossiers.
|
||||
assert!(
|
||||
@ -3351,8 +3531,8 @@ mod tests {
|
||||
fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() {
|
||||
// Cloisonnement : la prose MCP ne mentionne pas le protocole fichier
|
||||
// `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil.
|
||||
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
|
||||
assert!(
|
||||
!mcp.contains(".ideai/requests"),
|
||||
@ -3382,7 +3562,7 @@ mod tests {
|
||||
fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() {
|
||||
// Ordre : le briefing capacités apparaît AVANT le persona dans les 2 modes.
|
||||
// On ancre le brief sur un marqueur stable propre à chaque surface.
|
||||
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
|
||||
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
|
||||
let mcp_brief_at = mcp
|
||||
.find("idea_context_read")
|
||||
.expect("MCP capability brief present");
|
||||
@ -3392,7 +3572,7 @@ mod tests {
|
||||
"MCP capability brief must come before the persona"
|
||||
);
|
||||
|
||||
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
let file_brief_at = file
|
||||
.find(".ideai/CONTEXT.md")
|
||||
.expect("file capability brief present");
|
||||
@ -3478,7 +3658,8 @@ mod tests {
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
|
||||
let doc =
|
||||
compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), &[], false);
|
||||
|
||||
assert!(
|
||||
doc.contains("# Reprise de la conversation"),
|
||||
@ -3514,7 +3695,7 @@ mod tests {
|
||||
// objective = None ⇒ the section and the summary are present, but NO
|
||||
// `**Objectif :**` line.
|
||||
let h = handoff("Juste le résumé.", None);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);
|
||||
|
||||
assert!(
|
||||
doc.contains("# Reprise de la conversation"),
|
||||
@ -3532,7 +3713,7 @@ mod tests {
|
||||
// objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
|
||||
// line, but the section + summary still render.
|
||||
let h = handoff("Le résumé.", Some(" "));
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);
|
||||
|
||||
assert!(doc.contains("# Reprise de la conversation"));
|
||||
assert!(doc.contains("Le résumé."));
|
||||
@ -3546,7 +3727,7 @@ mod tests {
|
||||
fn compose_convention_file_without_handoff_omits_resume_section() {
|
||||
// handoff = None ⇒ no `# Reprise de la conversation` section at all
|
||||
// (zero regression for launches with no resume).
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
|
||||
assert!(
|
||||
!doc.contains("# Reprise de la conversation"),
|
||||
"no handoff ⇒ no resume section: {doc}"
|
||||
@ -3557,6 +3738,118 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// État du projet (lot LS4) — live-state preview section in the composer.
|
||||
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
|
||||
// (self-exclusion/cap/ordering are `resolve_live_state`'s job, covered by the
|
||||
// launch-level test). Here we pin the rendering contract.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn live_row(name: &str, status: WorkStatus, intent: &str) -> InjectedLiveRow {
|
||||
InjectedLiveRow {
|
||||
name: name.to_owned(),
|
||||
status,
|
||||
intent: intent.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_renders_live_state_rows_in_order_with_labels() {
|
||||
let rows = [
|
||||
live_row("Architect", WorkStatus::Working, "cadrage LS4"),
|
||||
live_row("DevBackend", WorkStatus::Blocked, "attend la review"),
|
||||
// Empty intent ⇒ the ` · intent` suffix is omitted (status only).
|
||||
live_row("QA", WorkStatus::Idle, ""),
|
||||
];
|
||||
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &rows, true);
|
||||
|
||||
// Section present, after the persona.
|
||||
assert!(doc.contains("# État du projet"), "section present: {doc}");
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
let section_at = doc.find("# État du projet").unwrap();
|
||||
assert!(
|
||||
persona_at < section_at,
|
||||
"live-state comes after the persona"
|
||||
);
|
||||
|
||||
// Exact line format `- **Name** — status[ · intent]`, status = WorkStatus::label.
|
||||
assert!(
|
||||
doc.contains("- **Architect** — working · cadrage LS4"),
|
||||
"working row with intent: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- **DevBackend** — blocked · attend la review"),
|
||||
"blocked row with intent: {doc}"
|
||||
);
|
||||
// QA has an empty intent ⇒ no ` · ` suffix, no trailing separator.
|
||||
assert!(
|
||||
doc.contains("- **QA** — idle\n"),
|
||||
"idle row, intent omitted: {doc}"
|
||||
);
|
||||
assert!(
|
||||
!doc.contains("**QA** — idle ·"),
|
||||
"empty intent must not render a ` · ` suffix: {doc}"
|
||||
);
|
||||
|
||||
// Manifest order is preserved: Architect before DevBackend before QA.
|
||||
let a = doc.find("**Architect**").unwrap();
|
||||
let b = doc.find("**DevBackend**").unwrap();
|
||||
let c = doc.find("**QA**").unwrap();
|
||||
assert!(a < b && b < c, "rows rendered in the given order: {doc}");
|
||||
|
||||
// No leakage: the section never carries UUIDs/progress/transcripts (none in the
|
||||
// rows ⇒ none in the output).
|
||||
assert!(!doc.contains("progress"), "progress never surfaced: {doc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_empty_live_state_is_identical_to_no_section() {
|
||||
// No live rows ⇒ the `# État du projet` section is omitted entirely, and the
|
||||
// document is byte-for-byte identical to a composition that never had one.
|
||||
let baseline =
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
|
||||
assert!(
|
||||
!baseline.contains("# État du projet"),
|
||||
"no live rows ⇒ no section"
|
||||
);
|
||||
// Strict byte equality: threading an empty slice changes nothing.
|
||||
let empty =
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
|
||||
assert_eq!(baseline, empty, "empty live-state ⇒ identical document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_live_state_placed_after_memory_before_resume() {
|
||||
// Placement contract: État du projet sits AFTER `# Mémoire projet` and BEFORE
|
||||
// `# Reprise de la conversation`.
|
||||
let memory = [mem(
|
||||
"git-optional",
|
||||
"Git optionnel",
|
||||
"git reste un simple tool",
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let h = handoff("Résumé : étape 2 finie.", Some("Livrer LS4"));
|
||||
let rows = [live_row("Architect", WorkStatus::Waiting, "revue")];
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&h),
|
||||
&rows,
|
||||
true,
|
||||
);
|
||||
|
||||
let memory_at = doc.find("# Mémoire projet").unwrap();
|
||||
let live_at = doc.find("# État du projet").unwrap();
|
||||
let resume_at = doc.find("# Reprise de la conversation").unwrap();
|
||||
assert!(
|
||||
memory_at < live_at && live_at < resume_at,
|
||||
"order must be Mémoire → État du projet → Reprise: {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
|
||||
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
|
||||
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
|
||||
|
||||
@ -29,10 +29,11 @@ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversa
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
|
||||
@ -37,15 +37,15 @@ pub use agent::{
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
|
||||
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
RESUME_PROMPT,
|
||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
||||
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
pub use embedder::{
|
||||
@ -81,8 +81,9 @@ pub use memory::{
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
|
||||
OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
|
||||
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
|
||||
RecordTurnProvider, WriteMemory,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
|
||||
@ -12,6 +12,6 @@ pub use context_guard::{
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use service::{
|
||||
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
|
||||
OrchestratorService, RecordTurnProvider,
|
||||
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
|
||||
OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
|
||||
@ -45,7 +45,7 @@ use crate::orchestrator::{
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
use crate::workstate::{UpdateLiveState, UpdateLiveStateInput};
|
||||
use crate::workstate::{GetLiveStateLean, UpdateLiveState, UpdateLiveStateInput};
|
||||
|
||||
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
|
||||
/// resizes the PTY to the real cell size on attach; these are sane starting rows
|
||||
@ -235,6 +235,23 @@ pub trait LiveStateProvider: Send + Sync {
|
||||
fn live_state_for(&self, root: &ProjectPath) -> Option<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.
|
||||
pub struct OrchestratorService {
|
||||
create_agent: Arc<CreateAgentFromScratch>,
|
||||
@ -337,6 +354,12 @@ pub struct OrchestratorService {
|
||||
/// régression). **Best-effort strict** : un échec de persistance ne transforme
|
||||
/// **jamais** un succès de délégation/reply en erreur.
|
||||
live_state: Option<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
|
||||
@ -403,6 +426,7 @@ impl OrchestratorService {
|
||||
memory_harvest: None,
|
||||
read_skill: None,
|
||||
live_state: None,
|
||||
live_state_read: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -529,6 +553,16 @@ impl OrchestratorService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil
|
||||
/// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ;
|
||||
/// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car
|
||||
/// le live-state est par project root et le port fixe son root à la construction.
|
||||
#[must_use]
|
||||
pub fn with_live_state_read(mut self, provider: Arc<dyn LiveStateReadProvider>) -> Self {
|
||||
self.live_state_read = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// Distille l'objectif d'un ticket en un `intent` court pour le live-state :
|
||||
/// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`]
|
||||
/// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne
|
||||
@ -729,6 +763,28 @@ impl OrchestratorService {
|
||||
content,
|
||||
requester,
|
||||
} => self.write_memory(project, slug, content, requester).await,
|
||||
OrchestratorCommand::ReadWorkState { requester } => {
|
||||
self.read_workstate(project, requester).await
|
||||
}
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent,
|
||||
status,
|
||||
intent,
|
||||
progress,
|
||||
ticket,
|
||||
last_delegation,
|
||||
} => {
|
||||
self.set_workstate(
|
||||
project,
|
||||
agent,
|
||||
status,
|
||||
intent,
|
||||
progress,
|
||||
ticket,
|
||||
last_delegation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -843,6 +899,105 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `workstate.read` / `idea_workstate_read` → the project's agent **live-state**
|
||||
/// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display
|
||||
/// name, returned inline as a JSON array. Entries whose agent is no longer in the
|
||||
/// manifest are dropped; `progress` is **never** exposed (the lean DTO has none).
|
||||
///
|
||||
/// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`.
|
||||
async fn read_workstate(
|
||||
&self,
|
||||
project: &Project,
|
||||
_requester: ConversationParty,
|
||||
) -> Result<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.
|
||||
async fn write_memory(
|
||||
&self,
|
||||
|
||||
@ -3188,3 +3188,215 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
"project root flowed into the projection ctx"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// État du projet injection at launch (lot LS4) — `resolve_live_state` owns the
|
||||
// manifest join + self-exclusion + ordering + cap; the composer renders it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the
|
||||
/// live-state preview injected into the convention file at launch.
|
||||
struct SeededLiveStore {
|
||||
state: Mutex<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 application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
|
||||
ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
UpdateLiveState,
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
|
||||
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
|
||||
TerminalSessions, UpdateAgentContext, UpdateLiveState,
|
||||
};
|
||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
||||
use domain::ports::LiveStateStore;
|
||||
@ -493,6 +493,17 @@ impl LiveStateProvider for TestLiveStateProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`LiveStateReadProvider`] (lot LS4, `idea_workstate_read`) yielding the same
|
||||
/// [`GetLiveStateLean`] over a shared recording store (root-agnostic for the test).
|
||||
struct TestLiveStateReadProvider {
|
||||
getter: Arc<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`.
|
||||
struct FixedMillisClock(i64);
|
||||
impl Clock for FixedMillisClock {
|
||||
@ -1297,6 +1308,14 @@ fn ask_fixture_full(
|
||||
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
|
||||
)),
|
||||
}) 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);
|
||||
|
||||
@ -1551,6 +1570,178 @@ async fn no_live_state_wired_means_no_write() {
|
||||
assert!(fx.live.entry_for(aid(1)).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LS4 — `idea_workstate_set` / `idea_workstate_read` service round-trip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `idea_workstate_set` writes the current agent's row, then `idea_workstate_read`
|
||||
/// returns it enriched with the **resolved display name** (via `ListAgents`), with the
|
||||
/// declared status/intent — and **never** the `progress` field.
|
||||
#[tokio::test]
|
||||
async fn workstate_set_then_read_round_trips_with_resolved_name_and_no_progress() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// set — keyed on the handshake agent id; carries a progress note (must not leak).
|
||||
let ack = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent: aid(1),
|
||||
status: WorkStatus::Working,
|
||||
intent: Some("ship LS4".to_owned()),
|
||||
progress: Some("secret internal note".to_owned()),
|
||||
ticket: None,
|
||||
last_delegation: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("set ok");
|
||||
// ACK only — no inline reply on the write path.
|
||||
assert!(ack.reply.is_none(), "set is ACK only, no reply: {ack:?}");
|
||||
|
||||
// read — the JSON array carries one row for the architect.
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::ReadWorkState {
|
||||
requester: ConversationParty::User,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read ok");
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(out.reply.as_deref().expect("read carries a reply")).unwrap();
|
||||
let rows = json.as_array().expect("array");
|
||||
assert_eq!(rows.len(), 1, "one live row expected: {json}");
|
||||
let row = &rows[0];
|
||||
// Name resolved from the manifest (not the bare UUID).
|
||||
assert_eq!(row["agent"], serde_json::json!("architect"));
|
||||
assert_eq!(row["status"], serde_json::json!("working"));
|
||||
assert_eq!(row["intent"], serde_json::json!("ship LS4"));
|
||||
// progress is NEVER exposed on read (the lean DTO has no such field).
|
||||
assert!(
|
||||
row.get("progress").is_none(),
|
||||
"progress must never surface on read: {row}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `idea_workstate_set` is last-writer-wins: a second set for the same agent replaces
|
||||
/// the row in place (never a duplicate), and the read reflects the latest write.
|
||||
#[tokio::test]
|
||||
async fn workstate_set_is_last_writer_wins() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
for (status, intent) in [
|
||||
(WorkStatus::Working, "first"),
|
||||
(WorkStatus::Blocked, "second"),
|
||||
] {
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent: aid(1),
|
||||
status,
|
||||
intent: Some(intent.to_owned()),
|
||||
progress: None,
|
||||
ticket: None,
|
||||
last_delegation: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("set ok");
|
||||
}
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::ReadWorkState {
|
||||
requester: ConversationParty::User,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read ok");
|
||||
let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap();
|
||||
let rows = json.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1, "LWW ⇒ exactly one row, no duplicate: {json}");
|
||||
assert_eq!(rows[0]["status"], serde_json::json!("blocked"));
|
||||
assert_eq!(rows[0]["intent"], serde_json::json!("second"));
|
||||
}
|
||||
|
||||
/// `idea_workstate_read` drops rows whose agent left (or never was in) the manifest —
|
||||
/// the manifest boundary is authoritative.
|
||||
#[tokio::test]
|
||||
async fn workstate_read_drops_off_manifest_rows() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
// aid(1) is in the manifest; aid(2) is NOT.
|
||||
for who in [aid(1), aid(2)] {
|
||||
fx.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent: who,
|
||||
status: WorkStatus::Working,
|
||||
intent: Some("x".to_owned()),
|
||||
progress: None,
|
||||
ticket: None,
|
||||
last_delegation: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("set ok");
|
||||
}
|
||||
|
||||
let out = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::ReadWorkState {
|
||||
requester: ConversationParty::User,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read ok");
|
||||
let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap();
|
||||
let rows = json.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1, "the off-manifest row is dropped: {json}");
|
||||
assert_eq!(rows[0]["agent"], serde_json::json!("architect"));
|
||||
}
|
||||
|
||||
/// An oversize `intent` (above the domain hard byte threshold) is **rejected** by the
|
||||
/// write path (the domain bound surfaces as `AppError::Invalid`), never silently stored.
|
||||
#[tokio::test]
|
||||
async fn workstate_set_oversize_intent_is_rejected() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
|
||||
let huge = "x".repeat(domain::live_state::FIELD_MAX_BYTES + 1);
|
||||
let err = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent: aid(1),
|
||||
status: WorkStatus::Working,
|
||||
intent: Some(huge),
|
||||
progress: None,
|
||||
ticket: None,
|
||||
last_delegation: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("oversize intent must be rejected");
|
||||
assert!(
|
||||
matches!(err, application::AppError::Invalid(_)),
|
||||
"oversize ⇒ AppError::Invalid, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The target returned to its prompt **without** `idea_reply`: the mediator's grace
|
||||
/// window expired and completed the turn "no reply". The awaiting ask must error out
|
||||
/// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang
|
||||
|
||||
Reference in New Issue
Block a user