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:
@ -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