chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -71,7 +71,8 @@ impl TempDir {
}
/// `<root>/.ideai/conversations/<conversationId>/providers.json.tmp` — the atomic-write tmp (P5).
fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf {
self.conversation_dir(conversation).join("providers.json.tmp")
self.conversation_dir(conversation)
.join("providers.json.tmp")
}
}
impl Drop for TempDir {
@ -119,10 +120,13 @@ async fn append_writes_one_valid_json_line_per_turn() {
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
for line in &lines {
let parsed: ConversationTurn =
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
let parsed: ConversationTurn = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
// camelCase shape leaks through to the file (sanity on the persisted format).
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
assert!(
line.contains("\"atMs\""),
"expected camelCase atMs in {line:?}"
);
let _ = parsed;
}
}
@ -144,7 +148,9 @@ async fn persists_across_a_fresh_instance_restart() {
(2, TurnRole::Response, "b"),
(3, TurnRole::Prompt, "c"),
] {
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
log.append(c, turn(c, turn_id(id), role, txt))
.await
.unwrap();
}
}
@ -184,7 +190,10 @@ async fn conversations_land_in_disjoint_files() {
assert_ne!(p1, p2, "the two conversations must use distinct files");
// No cross-leak through the API, and the c2 file holds only c2's single line.
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
assert_eq!(
texts(&log.read(c1, None).await.unwrap()),
vec!["c1-a", "c1-b"]
);
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
let c2_raw = std::fs::read_to_string(&p2).unwrap();
assert_eq!(
@ -192,7 +201,10 @@ async fn conversations_land_in_disjoint_files() {
1,
"c2 file must not contain c1's turns"
);
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
assert!(
!c2_raw.contains("c1-"),
"no c1 content leaked into c2's file"
);
}
// ---------------------------------------------------------------------------
@ -228,11 +240,21 @@ async fn corrupted_line_is_skipped_without_panic() {
// A fresh instance must skip the junk line and return only the two valid turns.
let log = FsConversationLog::new(&tmp.project_path());
let all = log.read(c, None).await.unwrap();
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
assert_eq!(
texts(&all),
vec!["good-1", "good-2"],
"garbage line skipped"
);
// `last` must be just as tolerant.
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
assert_eq!(
texts(&log.last(c, 5).await.unwrap()),
vec!["good-1", "good-2"]
);
// And the cursor still works across the corrupted tail.
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
assert_eq!(
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
vec!["good-2"]
);
}
#[tokio::test]
@ -260,7 +282,11 @@ async fn good_line_appended_after_corruption_is_still_read() {
.unwrap();
let all = log.read(c, None).await.unwrap();
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
assert_eq!(
texts(&all),
vec!["before", "after"],
"junk in the middle is skipped, both reals survive"
);
}
// ---------------------------------------------------------------------------
@ -274,7 +300,11 @@ async fn missing_conversation_reads_empty() {
// Nothing was ever appended: no .ideai dir at all.
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
assert!(log
.read(conv_id(99), Some(turn_id(1)))
.await
.unwrap()
.is_empty());
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
}
@ -293,8 +323,14 @@ async fn read_cursor_is_strictly_exclusive() {
.unwrap();
}
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
assert_eq!(
texts(&log.read(c, Some(turn_id(1))).await.unwrap()),
vec!["b", "c"]
);
assert_eq!(
texts(&log.read(c, Some(turn_id(2))).await.unwrap()),
vec!["c"]
);
// Cursor on the last id => nothing after.
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
@ -317,9 +353,15 @@ async fn last_contract_zero_and_bounds() {
// last(n < len) => the n last, in insertion order.
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
// last(n > len) => everything.
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
assert_eq!(
texts(&log.last(c, 10).await.unwrap()),
vec!["a", "b", "c", "d"]
);
// last(n == len) => everything.
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
assert_eq!(
texts(&log.last(c, 4).await.unwrap()),
vec!["a", "b", "c", "d"]
);
}
// ---------------------------------------------------------------------------
@ -352,7 +394,11 @@ async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
assert_eq!(
lines.len(),
2,
"two concurrent appends => two lines, got: {raw:?}"
);
for line in &lines {
serde_json::from_str::<ConversationTurn>(line)
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
@ -391,15 +437,23 @@ async fn handoff_round_trip_multiline_summary_with_objective() {
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
// pour éprouver la fidélité octet-pour-octet du corps.
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
let summary =
"# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
store.save(c, handoff.clone()).await.unwrap();
let loaded = store.load(c).await.unwrap();
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
assert_eq!(
loaded,
Some(handoff.clone()),
"round-trip doit redonner le Handoff exact"
);
let loaded = loaded.unwrap();
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
assert_eq!(
loaded.summary_md, summary,
"summary_md conservé octet pour octet"
);
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
}
@ -428,8 +482,14 @@ async fn handoff_round_trip_without_objective() {
// Sanity sur le format brut : pas de ligne `objective:` quand None.
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
assert!(
!raw.contains("objective:"),
"aucune clé objective ne doit être écrite, got: {raw:?}"
);
assert!(
raw.contains("upTo: "),
"upTo toujours présent, got: {raw:?}"
);
}
// ---------------------------------------------------------------------------
@ -464,8 +524,15 @@ async fn handoff_save_is_atomic_no_tmp_left_behind() {
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
);
// Le fichier final existe et est complet/relisible.
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
assert!(
tmp.handoff_path(c).exists(),
"handoff.md final doit exister"
);
assert_eq!(
store.load(c).await.unwrap(),
Some(handoff),
"contenu final lisible et complet"
);
}
// ---------------------------------------------------------------------------
@ -485,10 +552,20 @@ async fn handoff_overwrite_keeps_only_the_last() {
store.save(c, second.clone()).await.unwrap();
// load renvoie le dernier, aucune trace du premier.
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
assert_eq!(
store.load(c).await.unwrap(),
Some(second),
"load doit renvoyer le dernier save"
);
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
assert!(
!raw.contains("première version"),
"le contenu du premier save ne doit pas subsister"
);
assert!(
!raw.contains("obj-1"),
"l'objective du premier save ne doit pas subsister"
);
}
// ---------------------------------------------------------------------------
@ -515,11 +592,20 @@ async fn handoff_is_isolated_per_conversation() {
// Deux fichiers distincts sur disque.
let p1 = tmp.handoff_path(c1);
let p2 = tmp.handoff_path(c2);
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
assert_ne!(
p1, p2,
"deux conversations => deux fichiers handoff distincts"
);
assert!(p1.exists() && p2.exists());
let raw2 = std::fs::read_to_string(&p2).unwrap();
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
assert!(
!raw2.contains("résumé c1"),
"aucune fuite de c1 dans le handoff de c2"
);
assert!(
!raw2.contains("obj-c1"),
"aucune fuite de l'objective de c1 dans c2"
);
}
// ---------------------------------------------------------------------------
@ -537,11 +623,23 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
// (4) `upTo` non-UUID.
// (5) ligne de front-matter sans `:`.
let cases: [(u128, &str, &str); 5] = [
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
(
101,
"pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n",
"fence ouvrant absent",
),
(
102,
"---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n",
"fence fermant absent",
),
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
(
105,
"---\nligne sans deux-points\n---\ncorps\n",
"ligne front-matter sans `:`",
),
];
for (n, content, label) in cases {
@ -575,7 +673,10 @@ async fn handoff_corruption_yields_serialization_error_no_panic() {
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
fn turn_lines(summary_md: &str) -> Vec<&str> {
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
summary_md
.lines()
.filter(|l| l.starts_with("- **"))
.collect()
}
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
@ -595,7 +696,8 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
// Objectif extrait du 1er Prompt.
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
assert!(
h.summary_md.contains("**Objectif :** Implémente la feature X"),
h.summary_md
.contains("**Objectif :** Implémente la feature X"),
"ligne d'objectif attendue, got:\n{}",
h.summary_md
);
@ -603,7 +705,12 @@ async fn fold_none_renders_base_window_objective_and_cursor() {
assert_eq!(h.up_to, turn_id(3));
// Les 3 tours rendus, un par ligne, avec le bon label.
let lines = turn_lines(&h.summary_md);
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
assert_eq!(
lines.len(),
3,
"3 lignes-tours attendues, got:\n{}",
h.summary_md
);
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
assert_eq!(lines[1], "- **Tool:** ran grep");
assert_eq!(lines[2], "- **Response:** fait");
@ -626,11 +733,18 @@ async fn fold_is_incremental_does_not_re_pass_old_turns() {
// Le 2e appel ne re-fournit QUE t6.
let h2 = s
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
.fold(
Some(h1.clone()),
&[turn(c, turn_id(6), TurnRole::Response, "r6")],
)
.await;
let lines = turn_lines(&h2.summary_md);
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
assert_eq!(
lines.len(),
6,
"t1..t6 reconstitués depuis prev + incrément"
);
assert_eq!(lines[0], "- **Prompt:** obj un");
assert_eq!(lines[5], "- **Response:** r6");
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
@ -686,7 +800,10 @@ async fn fold_keeps_existing_objective_over_new_prompt() {
);
let h = s
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
.fold(
Some(prev),
&[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")],
)
.await;
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
@ -752,10 +869,13 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
let lines = turn_lines(&h1.summary_md);
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
assert_eq!(
lines[1],
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
lines.len(),
2,
"2 lignes-tours seulement, pas de ligne fantôme"
);
assert_eq!(
lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
"texte multi-lignes + marqueur aplati en une ligne"
);
@ -765,10 +885,17 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
let h2 = s
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
.fold(
Some(h1.clone()),
&[turn(c, turn_id(3), TurnRole::Response, "suite")],
)
.await;
let lines2 = turn_lines(&h2.summary_md);
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
assert_eq!(
lines2.len(),
3,
"fenêtre cohérente après repli (pas de split parasite)"
);
assert_eq!(lines2[2], "- **Response:** suite");
assert_eq!(h2.up_to, turn_id(3));
}
@ -902,8 +1029,16 @@ async fn provider_corrupted_file_yields_serialization_error_no_panic() {
// Cas de contenus non-désérialisables en map<String,String>.
let cases: [(u128, &str, &str); 3] = [
(201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"),
(202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"),
(
201,
"{ ceci n'est pas du json",
"JSON syntaxiquement invalide",
),
(
202,
"[\"pas\", \"un\", \"objet\"]",
"JSON valide mais pas un objet map",
),
(203, "{\"claude\": 123}", "valeur non-String"),
];
@ -986,8 +1121,14 @@ async fn provider_is_isolated_per_conversation() {
store.set(c2, "claude", "c2-id").await.unwrap();
// Chacune relit la sienne.
assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string()));
assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string()));
assert_eq!(
store.get(c1, "claude").await.unwrap(),
Some("c1-id".to_string())
);
assert_eq!(
store.get(c2, "claude").await.unwrap(),
Some("c2-id".to_string())
);
// Deux fichiers distincts sur disque, sans fuite de contenu.
let p1 = tmp.providers_path(c1);
@ -995,5 +1136,8 @@ async fn provider_is_isolated_per_conversation() {
assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts");
assert!(p1.exists() && p2.exists());
let raw2 = std::fs::read_to_string(&p2).unwrap();
assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2");
assert!(
!raw2.contains("c1-id"),
"aucune fuite de c1 dans le providers.json de c2"
);
}

View File

@ -27,9 +27,9 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::NodeId;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::NodeId;
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
@ -52,11 +52,11 @@ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use serde_json::{json, Value};
@ -296,7 +296,6 @@ impl PtyPort for FakePty {
}
}
#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
@ -468,7 +467,9 @@ fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec<u8> {
/// Extracts the single text content block of a successful `tools/call` result.
fn result_text(result: &Value) -> &str {
result["content"][0]["text"].as_str().expect("text content block")
result["content"][0]["text"]
.as_str()
.expect("text content block")
}
// ---------------------------------------------------------------------------
@ -485,15 +486,15 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
}))
.unwrap();
let response = server.handle_raw(&raw).await.expect("tools/list owes a reply");
let response = server
.handle_raw(&raw)
.await
.expect("tools/list owes a reply");
assert!(response.error.is_none(), "got error: {:?}", response.error);
let result = response.result.expect("result");
let tools = result["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"idea_list_agents",
@ -509,7 +510,10 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
"idea_memory_read",
"idea_memory_write",
] {
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
assert!(
names.contains(&expected),
"missing tool {expected}; got {names:?}"
);
}
assert_eq!(
tools.len(),
@ -520,7 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
// Every tool advertises an object input schema.
for t in tools {
assert_eq!(
t["inputSchema"]["type"], json!("object"),
t["inputSchema"]["type"],
json!("object"),
"tool {} must declare an object input schema",
t["name"]
);
@ -544,7 +549,11 @@ async fn launch_agent_call_creates_and_launches_the_agent() {
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
@ -630,7 +639,11 @@ async fn ask_agent_returns_target_reply_inline() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
seed_live_pty(
&sessions,
agent_id,
SessionId::from_uuid(Uuid::from_u128(4242)),
);
let ask_server = server(Arc::clone(&service));
let ask = tokio::spawn(async move {
@ -655,7 +668,10 @@ async fn ask_agent_returns_target_reply_inline() {
// requester, which `for_requester` injects as the `from` of the Reply command.
let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string());
let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" }));
let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed");
let reply_resp = reply_server
.handle_raw(&reply_raw)
.await
.expect("reply owed");
assert_eq!(reply_resp.result.expect("result")["isError"], json!(false));
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
@ -663,7 +679,11 @@ async fn ask_agent_returns_target_reply_inline() {
.expect("ask completes after reply")
.expect("join ok");
assert_eq!(response.id, json!(7), "id must be echoed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
assert_eq!(
@ -751,7 +771,10 @@ async fn failed_command_is_tool_error_not_transport_error_and_server_survives()
}))
.unwrap();
let again = server.handle_raw(&raw).await.expect("server still serves");
assert!(again.result.is_some(), "server must survive a failed command");
assert!(
again.result.is_some(),
"server must survive a failed command"
);
}
// ---------------------------------------------------------------------------
@ -898,7 +921,10 @@ async fn scripted_session_over_memory_transport_round_trips() {
// Exactly two responses (init + list); the notification produced none.
let first = rx.recv().await.expect("init response");
let second = rx.recv().await.expect("list response");
assert!(rx.recv().await.is_none(), "notification must not be answered");
assert!(
rx.recv().await.is_none(),
"notification must not be answered"
);
let first: Value = serde_json::from_slice(&first).unwrap();
let second: Value = serde_json::from_slice(&second).unwrap();
@ -927,7 +953,11 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
assert_eq!(response.result.expect("result")["isError"], json!(false));
// Exactly one OrchestratorRequestProcessed event, tagged as the MCP door.
@ -951,7 +981,10 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() {
assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp");
assert_eq!(action, "idea_launch_agent", "action mirrors the tool name");
assert!(*ok, "the launch succeeded");
assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind");
assert_eq!(
requester_id, "mcp",
"stable mcp label until per-session bind"
);
}
other => panic!("expected OrchestratorRequestProcessed, got {other:?}"),
}
@ -976,9 +1009,15 @@ async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source()
.iter()
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
.collect();
assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}");
assert_eq!(
processed.len(),
1,
"a failed call still beacons; got {events:?}"
);
match processed[0] {
DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => {
DomainEvent::OrchestratorRequestProcessed {
ok, source, action, ..
} => {
assert!(!*ok, "the dispatch failed → ok = false");
assert_eq!(*source, OrchestrationSource::Mcp);
assert_eq!(action, "idea_stop_agent");
@ -1001,7 +1040,11 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
json!({ "target": "dev-backend", "profile": "claude-code" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
assert_eq!(response.result.expect("result")["isError"], json!(false));
// The command really ran (agent created) — proof the no-op sink path is live.
assert_eq!(contexts.entries().len(), 1);

View File

@ -16,10 +16,10 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::NodeId;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ids::NodeId;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
@ -295,7 +295,6 @@ impl PtyPort for FakePty {
}
}
#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
@ -471,11 +470,7 @@ fn build_service_with_mailbox(
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(
sessions: &TerminalSessions,
agent_id: AgentId,
session_id: SessionId,
) {
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
@ -650,7 +645,11 @@ async fn ask_request_surfaces_reply_alongside_detail() {
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
// Target already live in the PTY registry, so the ask reuses its terminal.
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
seed_live_pty(
&sessions,
agent_id,
SessionId::from_uuid(Uuid::from_u128(4242)),
);
let req = tmp.0.join("ask-req.json");
std::fs::write(
@ -770,7 +769,8 @@ async fn non_ask_request_omits_reply_key() {
#[test]
fn legacy_response_without_reply_round_trips_without_reintroducing_key() {
// A response payload exactly as a pre-D6b IdeA would have written it.
let legacy = r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
let legacy =
r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#;
let parsed: OrchestratorResponse =
serde_json::from_str(legacy).expect("legacy response must still parse");
@ -873,7 +873,12 @@ async fn watcher_publishes_processed_event_tagged_file_source() {
let event = found.expect("watcher must publish a processed beacon within the poll budget");
match event {
DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => {
DomainEvent::OrchestratorRequestProcessed {
source,
ok,
requester_id,
..
} => {
assert_eq!(source, OrchestrationSource::File, "fs door must tag File");
assert!(ok, "the spawn request succeeded");
assert_eq!(requester_id, "Main", "requester id = request subdirectory");