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:
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user