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:
@ -1312,7 +1312,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
"memory section present: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"),
|
||||
doc.contains(
|
||||
"- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"
|
||||
),
|
||||
"first memory line exact format: {doc}"
|
||||
);
|
||||
assert!(
|
||||
@ -1589,23 +1591,23 @@ async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() {
|
||||
async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
// Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at
|
||||
// <run_dir>/.mcp.json with a non-empty, coherent MCP server declaration.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let mcp = writes_ending_with(&fs, "/.mcp.json");
|
||||
assert_eq!(mcp.len(), 1, "exactly one MCP config file written");
|
||||
assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir");
|
||||
assert_eq!(
|
||||
mcp[0].0,
|
||||
format!("{run_dir}/.mcp.json"),
|
||||
"written at run dir"
|
||||
);
|
||||
|
||||
let body = String::from_utf8(mcp[0].1.clone()).unwrap();
|
||||
assert!(!body.trim().is_empty(), "MCP declaration is non-empty");
|
||||
@ -1615,25 +1617,20 @@ async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() {
|
||||
"MCP declaration must declare the IdeA MCP server, got: {body}"
|
||||
);
|
||||
// It is valid JSON.
|
||||
let _: serde_json::Value =
|
||||
serde_json::from_str(&body).expect("MCP declaration is valid JSON");
|
||||
let _: serde_json::Value = serde_json::from_str(&body).expect("MCP declaration is valid JSON");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_is_non_clobbering() {
|
||||
// Test 3 — if <run_dir>/.mcp.json already exists, the launch must NOT overwrite
|
||||
// it: no write is recorded against that path.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
// Pre-existing MCP config file on disk.
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
@ -1649,17 +1646,13 @@ async fn launch_mcp_config_file_is_non_clobbering() {
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
||||
// Test 4 — a write failure on the MCP config file must NOT fail the launch.
|
||||
let profile = profile_with_mcp(
|
||||
pid(9),
|
||||
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
||||
let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap());
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
// Force the MCP config write to fail.
|
||||
fs.fail_write_suffix("/.mcp.json");
|
||||
|
||||
@ -1676,13 +1669,12 @@ async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
||||
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
||||
// by the run-dir config path.
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
@ -1704,13 +1696,12 @@ async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_env_appends_var_and_path_to_env() {
|
||||
// Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, <run_dir>).
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
@ -1761,13 +1752,12 @@ async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() {
|
||||
// declaration is the REAL one: command == exe, and args == the ordered
|
||||
// ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester",
|
||||
// requester]. Structure-asserted via parsed JSON (not a fragile string match).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
let exe = "/abs/idea";
|
||||
let endpoint = "/run/idea-mcp/abc.sock";
|
||||
@ -1820,13 +1810,12 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() {
|
||||
// M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays
|
||||
// valid JSON (parse OK, asserted by read_single_mcp_json) and the values are
|
||||
// faithfully preserved (correct escaping, no corruption).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
let exe = r"C:\Program Files\IdeA\idea.exe";
|
||||
let endpoint = r"/tmp/idea mcp/a b\c.sock";
|
||||
@ -1867,13 +1856,12 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() {
|
||||
async fn launch_mcp_runtime_none_writes_minimal_declaration() {
|
||||
// M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is
|
||||
// written: command == "idea", args == ["mcp-server"]. Still valid JSON.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
// launch_input has mcp_runtime: None.
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
@ -1928,22 +1916,28 @@ async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_is_non_clobbering() {
|
||||
// M5d-5 — a pre-existing .mcp.json is NOT overwritten, even when a real runtime
|
||||
// is injected (unchanged non-clobbering contract from M1).
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
async fn launch_mcp_runtime_clobbers_stale_config_with_fresh_declaration() {
|
||||
// M5d-5 — with a real injected runtime, a pre-existing .mcp.json MUST be
|
||||
// regenerated and CLOBBERED on every (re)launch: its `command` (the IdeA exe
|
||||
// path) and endpoint drift between runs (the AppImage internal mount path
|
||||
// changes at each remount/reboot), so a stale declaration would point the bridge
|
||||
// at a dead binary/socket. `.mcp.json` is IdeA-managed, so clobbering is safe.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
// A stale file already on disk (e.g. written by a previous run pointing at a
|
||||
// now-dead AppImage mount).
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
let exe = "/abs/idea";
|
||||
let endpoint = "/run/idea-mcp/abc.sock";
|
||||
let runtime = application::McpRuntime {
|
||||
exe: "/abs/idea".to_owned(),
|
||||
endpoint: "/run/idea-mcp/abc.sock".to_owned(),
|
||||
exe: exe.to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "agent-7".to_owned(),
|
||||
};
|
||||
@ -1953,9 +1947,118 @@ async fn launch_mcp_runtime_is_non_clobbering() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The fresh declaration is written despite the pre-existing file, and it carries
|
||||
// the current exe path / endpoint.
|
||||
let doc = read_single_mcp_json(&fs);
|
||||
let server = &doc["mcpServers"]["idea"];
|
||||
assert_eq!(
|
||||
server["command"].as_str(),
|
||||
Some(exe),
|
||||
"a stale .mcp.json must be clobbered with the fresh exe path, got: {doc}"
|
||||
);
|
||||
let args: Vec<&str> = server["args"]
|
||||
.as_array()
|
||||
.expect("args must be a JSON array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
args.get(2).copied(),
|
||||
Some(endpoint),
|
||||
"the clobbered declaration must carry the fresh endpoint, got: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_none_is_non_clobbering() {
|
||||
// M5d-5b — WITHOUT a runtime (orchestrator / hot-swap / tests) only the degraded
|
||||
// minimal declaration is available, so a pre-existing .mcp.json must NOT be
|
||||
// overwritten: we never replace a real declaration with the minimal one.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
// launch_input has mcp_runtime: None.
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(
|
||||
writes_ending_with(&fs, "/.mcp.json").is_empty(),
|
||||
"an existing .mcp.json must not be clobbered even with an injected runtime"
|
||||
"without a runtime, an existing .mcp.json must not be clobbered with the minimal decl"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_mcp_runtime_remount_clobbers_with_run_specific_facts() {
|
||||
// M5d-5c (QA) — direct reproduction of the AppImage-remount bug: across two
|
||||
// independent app sessions (= two reboots, each with a *different* mount path
|
||||
// and a *different* loopback socket), each launch must clobber a pre-existing
|
||||
// stale `.mcp.json` with the facts of THAT run. This proves the file is rebuilt
|
||||
// per-launch from the live runtime — never frozen on a previous run's dead
|
||||
// binary/socket. Two fixtures are used on purpose: relaunching the *same* live
|
||||
// agent would short-circuit through the reattach guard (no respawn), so it
|
||||
// would not exercise a second `apply_mcp_config`.
|
||||
let launch_once = |exe: &'static str, endpoint: &'static str| async move {
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
|
||||
profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
// A stale declaration left by the previous boot is already on disk.
|
||||
fs.mark_existing(&format!("{run_dir}/.mcp.json"));
|
||||
|
||||
let runtime = application::McpRuntime {
|
||||
exe: exe.to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "agent-7".to_owned(),
|
||||
};
|
||||
launch
|
||||
.execute(launch_input_with_runtime(agent.id, runtime))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let doc = read_single_mcp_json(&fs);
|
||||
let server = &doc["mcpServers"]["idea"];
|
||||
let command = server["command"].as_str().unwrap().to_owned();
|
||||
let args: Vec<String> = server["args"]
|
||||
.as_array()
|
||||
.expect("args must be a JSON array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
(command, args)
|
||||
};
|
||||
|
||||
// Boot #1: mount path A + socket A.
|
||||
let exe_a = "/tmp/.mount_IdeA_AAA/idea";
|
||||
let endpoint_a = "/run/idea-mcp/boot-a.sock";
|
||||
let (command_a, args_a) = launch_once(exe_a, endpoint_a).await;
|
||||
assert_eq!(command_a, exe_a, "boot #1 must carry mount path A");
|
||||
assert_eq!(args_a.get(2).map(String::as_str), Some(endpoint_a));
|
||||
|
||||
// Boot #2: a *new* mount path + a *new* socket (the very drift that caused the
|
||||
// bug). The declaration must follow the live facts, not the previous boot's.
|
||||
let exe_b = "/tmp/.mount_IdeA_BBB/idea";
|
||||
let endpoint_b = "/run/idea-mcp/boot-b.sock";
|
||||
let (command_b, args_b) = launch_once(exe_b, endpoint_b).await;
|
||||
assert_eq!(command_b, exe_b, "boot #2 must carry the NEW mount path");
|
||||
assert_eq!(args_b.get(2).map(String::as_str), Some(endpoint_b));
|
||||
|
||||
// And nothing from the stale boot #1 leaks into the boot #2 declaration.
|
||||
assert_ne!(
|
||||
command_b, exe_a,
|
||||
"boot #2 must not reuse boot #1's dead exe"
|
||||
);
|
||||
assert!(
|
||||
!args_b.iter().any(|a| a == endpoint_a),
|
||||
"boot #2 must not reuse boot #1's dead endpoint, got: {args_b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -2122,11 +2225,7 @@ impl HandoffStore for FakeHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2338,8 +2437,8 @@ async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||
#[tokio::test]
|
||||
async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() {
|
||||
let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais.
|
||||
// (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé
|
||||
// == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure).
|
||||
// (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé
|
||||
// == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure).
|
||||
let save_key = ConversationId::for_pair(ConversationParty::User, agent_party);
|
||||
// (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture.
|
||||
let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party);
|
||||
@ -2383,14 +2482,15 @@ async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() {
|
||||
#[tokio::test]
|
||||
async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() {
|
||||
let agent_party = ConversationParty::agent(aid(1)); // agent du harnais.
|
||||
// La cellule porte SA clé de paire (celle que P8a aurait persistée)…
|
||||
// La cellule porte SA clé de paire (celle que P8a aurait persistée)…
|
||||
let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party);
|
||||
// …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent).
|
||||
let other_key = ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(aid(2)),
|
||||
let other_key =
|
||||
ConversationId::for_pair(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
assert_ne!(
|
||||
leaf_key, other_key,
|
||||
"préalable : deux clés de paire distinctes"
|
||||
);
|
||||
assert_ne!(leaf_key, other_key, "préalable : deux clés de paire distinctes");
|
||||
|
||||
let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X")));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
|
||||
@ -46,9 +46,7 @@ use domain::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions,
|
||||
};
|
||||
use application::{ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore) — manifest + md_path → content
|
||||
@ -675,10 +673,7 @@ impl domain::HandoffStore for FakeHandoffs {
|
||||
}
|
||||
|
||||
impl application::HandoffProvider for FakeHandoffs {
|
||||
fn handoff_store_for(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Option<Arc<dyn domain::HandoffStore>> {
|
||||
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn domain::HandoffStore>> {
|
||||
Some(Arc::new(self.clone()))
|
||||
}
|
||||
}
|
||||
@ -860,7 +855,11 @@ async fn success_mutates_manifest_to_new_profile() {
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert_eq!(out.agent.profile_id, pid(2), "returned agent carries new profile");
|
||||
assert_eq!(
|
||||
out.agent.profile_id,
|
||||
pid(2),
|
||||
"returned agent carries new profile"
|
||||
);
|
||||
assert_eq!(
|
||||
f.contexts.profile_of(&agent.id),
|
||||
Some(pid(2)),
|
||||
@ -1017,7 +1016,10 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() {
|
||||
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
||||
// The relaunched session is returned and pinned on the SAME cell N.
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777), "relaunch adopts the new PTY id");
|
||||
// The relaunched session is registered and tagged for this agent.
|
||||
assert!(matches!(
|
||||
@ -1111,8 +1113,11 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
|
||||
// A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
|
||||
let pair = "11111111-1111-1111-1111-111111111111";
|
||||
f.handoffs
|
||||
.seed(pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d"));
|
||||
f.handoffs.seed(
|
||||
pair,
|
||||
"Résumé : on a fini l'étape 2.",
|
||||
Some("Livrer le lot P8d"),
|
||||
);
|
||||
|
||||
// Live agent on cell N with a foreign engine resumable cache.
|
||||
let host = nid(5);
|
||||
@ -1138,7 +1143,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
// The old PTY was killed and a single relaunch happened in the SAME cell.
|
||||
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
|
||||
// The pair id is preserved on the persisted leaf; the engine cache is cleared.
|
||||
assert_eq!(
|
||||
@ -1171,7 +1179,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
|
||||
// The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
|
||||
// None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
|
||||
// by the handoff). It is emphatically NOT Resume{"engine-old"}.
|
||||
let plan = f.runtime.last_plan().expect("the relaunch prepared an invocation");
|
||||
let plan = f
|
||||
.runtime
|
||||
.last_plan()
|
||||
.expect("the relaunch prepared an invocation");
|
||||
assert_eq!(
|
||||
plan,
|
||||
SessionPlan::None,
|
||||
|
||||
@ -61,7 +61,12 @@ impl InMemoryConversationLog {
|
||||
}
|
||||
|
||||
fn thread(&self, conv: ConversationId) -> Vec<ConversationTurn> {
|
||||
self.threads.lock().unwrap().get(&conv).cloned().unwrap_or_default()
|
||||
self.threads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&conv)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,11 +161,7 @@ impl HandoffStore for InMemoryHandoffStore {
|
||||
Ok(self.store.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
if let Some(err) = self.fail_save.lock().unwrap().clone() {
|
||||
return Err(err);
|
||||
}
|
||||
@ -219,7 +220,9 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "hello world");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record should succeed");
|
||||
uc.record(conv, t1.clone())
|
||||
.await
|
||||
.expect("record should succeed");
|
||||
|
||||
// Log contains the turn.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
@ -227,7 +230,11 @@ async fn single_record_appends_turn_and_advances_handoff() {
|
||||
// Handoff advanced: up_to == turn id, summary contains the turn text.
|
||||
let h = handoffs.loaded(conv).expect("handoff should be saved");
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
assert!(h.summary_md.contains("hello world"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("hello world"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// First fold: prev = None, exactly one new turn.
|
||||
assert_eq!(summarizer.calls(), vec![(false, 1)]);
|
||||
@ -254,8 +261,16 @@ async fn two_records_fold_incrementally_without_full_reread() {
|
||||
// Handoff up_to == t2.id; summary contains both texts.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert_eq!(h.up_to, t2.id);
|
||||
assert!(h.summary_md.contains("first-text"), "summary={:?}", h.summary_md);
|
||||
assert!(h.summary_md.contains("second-text"), "summary={:?}", h.summary_md);
|
||||
assert!(
|
||||
h.summary_md.contains("first-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
assert!(
|
||||
h.summary_md.contains("second-text"),
|
||||
"summary={:?}",
|
||||
h.summary_md
|
||||
);
|
||||
|
||||
// Proof of incrementality: prev None then Some, and len == 1 on BOTH calls
|
||||
// (never 2 → the whole log is never re-read into the fold).
|
||||
@ -311,9 +326,9 @@ async fn load_error_propagates_as_store() {
|
||||
#[tokio::test]
|
||||
async fn save_error_propagates_as_store() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(StoreError::Serialization(
|
||||
"save boom".to_owned(),
|
||||
)));
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::failing_save(
|
||||
StoreError::Serialization("save boom".to_owned()),
|
||||
));
|
||||
let summarizer = Arc::new(RecordingSummarizer::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath,
|
||||
StoreError,
|
||||
@ -26,7 +27,6 @@ use domain::ports::{
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::{
|
||||
AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell,
|
||||
NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild,
|
||||
@ -521,12 +521,7 @@ async fn resume_supported_follows_profile() {
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
plain_leaf,
|
||||
Some(plain_agent),
|
||||
Some("c2"),
|
||||
true,
|
||||
)),
|
||||
node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)),
|
||||
weight: 1.0,
|
||||
},
|
||||
],
|
||||
@ -537,17 +532,16 @@ async fn resume_supported_follows_profile() {
|
||||
entry(supported_agent, "Resumable", pid(1)),
|
||||
entry(plain_agent, "Plain", pid(2)),
|
||||
]);
|
||||
let profiles =
|
||||
FakeProfiles::new(vec![profile_with_session(pid(1)), profile_no_session(pid(2))]);
|
||||
let profiles = FakeProfiles::new(vec![
|
||||
profile_with_session(pid(1)),
|
||||
profile_no_session(pid(2)),
|
||||
]);
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 2, "both still listed: {out:?}");
|
||||
|
||||
let supported = out
|
||||
.iter()
|
||||
.find(|r| r.agent_id == supported_agent)
|
||||
.unwrap();
|
||||
let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap();
|
||||
assert!(supported.resume_supported, "profile pid(1) has a session");
|
||||
|
||||
let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap();
|
||||
@ -620,7 +614,12 @@ async fn agent_absent_from_manifest_is_ignored() {
|
||||
direction: Direction::Row,
|
||||
children: vec![
|
||||
WeightedChild {
|
||||
node: LayoutNode::Leaf(agent_leaf(orphan_leaf, Some(orphan_agent), Some("c1"), true)),
|
||||
node: LayoutNode::Leaf(agent_leaf(
|
||||
orphan_leaf,
|
||||
Some(orphan_agent),
|
||||
Some("c1"),
|
||||
true,
|
||||
)),
|
||||
weight: 1.0,
|
||||
},
|
||||
WeightedChild {
|
||||
@ -667,7 +666,11 @@ async fn failing_profile_store_lists_agents_without_resume_support() {
|
||||
let uc = make_use_case(&store, &fs, &contexts, &profiles);
|
||||
|
||||
let out = run(&uc).await;
|
||||
assert_eq!(out.len(), 1, "agent still listed despite profile failure: {out:?}");
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"agent still listed despite profile failure: {out:?}"
|
||||
);
|
||||
assert_eq!(out[0].agent_id, agent);
|
||||
assert!(
|
||||
!out[0].resume_supported,
|
||||
|
||||
@ -343,10 +343,10 @@ impl PtyPort for FakePty {
|
||||
})
|
||||
}
|
||||
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
||||
self.writes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((handle.session_id, String::from_utf8_lossy(data).into_owned()));
|
||||
self.writes.lock().unwrap().push((
|
||||
handle.session_id,
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
@ -380,7 +380,6 @@ impl EventBus for SpyBus {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -655,13 +654,19 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() {
|
||||
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}");
|
||||
match err {
|
||||
application::AppError::AgentAlreadyRunning { node_id, .. } => {
|
||||
assert_eq!(node_id, first_cell, "reports the live host cell, not target");
|
||||
assert_eq!(
|
||||
node_id, first_cell,
|
||||
"reports the live host cell, not target"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
|
||||
}
|
||||
|
||||
let session = fx.sessions.session(&sid(777)).expect("live session");
|
||||
assert_eq!(session.node_id, first_cell, "session stays on its host cell");
|
||||
assert_eq!(
|
||||
session.node_id, first_cell,
|
||||
"session stays on its host cell"
|
||||
);
|
||||
assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn");
|
||||
}
|
||||
|
||||
@ -784,7 +789,6 @@ async fn create_skill_honours_global_scope() {
|
||||
assert_eq!(saved[0].scope, SkillScope::Global);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4)
|
||||
//
|
||||
@ -826,7 +830,11 @@ impl TestMailbox {
|
||||
}
|
||||
}
|
||||
fn pending(&self, agent: &AgentId) -> usize {
|
||||
self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len)
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.map_or(0, VecDeque::len)
|
||||
}
|
||||
/// Ids of the tickets queued for `agent`, in FIFO order (test inspection).
|
||||
fn ticket_ids(&self, agent: &AgentId) -> Vec<TicketId> {
|
||||
@ -953,7 +961,10 @@ impl InputMediator for TestMediator {
|
||||
self.preempts.lock().unwrap().push(agent);
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle);
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
}
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
self.busy
|
||||
@ -1004,7 +1015,9 @@ impl ConversationRegistry for TestConversations {
|
||||
}
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef) {
|
||||
if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) {
|
||||
c.session = ConversationSession::Live { handle_ref: session };
|
||||
c.session = ConversationSession::Live {
|
||||
handle_ref: session,
|
||||
};
|
||||
}
|
||||
}
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
|
||||
@ -1131,8 +1144,7 @@ async fn await_until<F: Fn() -> bool>(cond: F) {
|
||||
.expect("condition never reached within guard (possible hang/deadlock)");
|
||||
}
|
||||
|
||||
const ASK_JSON: &str =
|
||||
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
||||
|
||||
/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected
|
||||
/// path, exactly what the MCP server produces from a peer's `idea_reply`).
|
||||
@ -1171,12 +1183,25 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The task was written into the target's terminal, prefixed for idea_reply.
|
||||
let writes = fx.pty.writes_for(sid(800));
|
||||
assert_eq!(writes.len(), 1, "exactly one task write to the live terminal");
|
||||
assert_eq!(
|
||||
writes.len(),
|
||||
1,
|
||||
"exactly one task write to the live terminal"
|
||||
);
|
||||
assert!(writes[0].contains("Analyse §17"), "task body written");
|
||||
assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present");
|
||||
assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue");
|
||||
assert!(
|
||||
writes[0].contains("[IdeA · tâche"),
|
||||
"delegated-task prefix present"
|
||||
);
|
||||
assert!(
|
||||
writes[0].contains("idea_reply") || writes[0].contains("ticket"),
|
||||
"carries ticket/idea_reply cue"
|
||||
);
|
||||
// No PTY spawned: the live terminal was reused.
|
||||
assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"live target reused, not respawned"
|
||||
);
|
||||
|
||||
// The target renders its result via idea_reply ⇒ resolves the head ticket.
|
||||
fx.service
|
||||
@ -1242,7 +1267,11 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The dead target was launched as a PTY (spawn recorded, session registered).
|
||||
assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY");
|
||||
assert_eq!(
|
||||
fx.pty.spawns(),
|
||||
vec![sid(777)],
|
||||
"dead target launched as PTY"
|
||||
);
|
||||
assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777)));
|
||||
// The delegated task was written into the freshly-launched terminal (the Stdin
|
||||
// context injection may also write the persona, so we look for the task prefix).
|
||||
@ -1289,7 +1318,10 @@ async fn ask_success_publishes_agent_replied_event() {
|
||||
.events()
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)),
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
} => Some((agent_id, reply_len)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("AgentReplied must be published");
|
||||
@ -1358,7 +1390,11 @@ async fn reply_without_pending_ask_is_invalid() {
|
||||
.dispatch(&project(), reply_cmd(aid(7), "orphan result"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"orphan reply is typed INVALID: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional
|
||||
@ -1417,8 +1453,12 @@ async fn ask_different_targets_run_in_parallel() {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
let fx = ask_fixture(contexts);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(801));
|
||||
@ -1498,10 +1538,10 @@ impl FileSystem for CapturingFs {
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned()));
|
||||
self.0.lock().unwrap().push((
|
||||
path.as_str().to_owned(),
|
||||
String::from_utf8_lossy(data).into_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
||||
@ -1640,8 +1680,7 @@ fn ask_fixture_ex(
|
||||
.with_events(Arc::new(bus.clone()));
|
||||
|
||||
if let Some(p) = provider {
|
||||
service =
|
||||
service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
|
||||
}
|
||||
|
||||
AskFixtureEx {
|
||||
@ -1677,7 +1716,11 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
|
||||
let calls = provider.calls();
|
||||
assert_eq!(calls.len(), 1, "provider interrogé exactement une fois");
|
||||
assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet");
|
||||
assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)");
|
||||
assert_eq!(
|
||||
calls[0].1,
|
||||
aid(1),
|
||||
"interrogé pour la cible relancée (= --requester)"
|
||||
);
|
||||
|
||||
// La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime.
|
||||
let decl = fx
|
||||
@ -1780,13 +1823,20 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"garde F2 = erreur typée Invalid: {err:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"),
|
||||
"message explicite sur le pont non supporté: {msg}"
|
||||
);
|
||||
assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée");
|
||||
assert!(
|
||||
fx.pty.spawns().is_empty(),
|
||||
"aucun lancement sur cible refusée"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé");
|
||||
}
|
||||
|
||||
@ -1856,7 +1906,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
|
||||
None,
|
||||
));
|
||||
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
||||
let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)));
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::new(pty.clone()),
|
||||
Arc::clone(&sessions),
|
||||
));
|
||||
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
||||
let create_skill = Arc::new(CreateSkill::new(
|
||||
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
|
||||
@ -1898,8 +1951,12 @@ fn seed_two_agents(contexts: &FakeContexts) {
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
|
||||
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(a.context_path.clone(), "# a".to_owned());
|
||||
inner
|
||||
.contents
|
||||
.insert(b.context_path.clone(), "# b".to_owned());
|
||||
}
|
||||
|
||||
/// An `agent.message` from agent A (uuid requester) to a named target.
|
||||
@ -1920,15 +1977,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B")))
|
||||
.await
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "do B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)));
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
@ -1941,7 +2002,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
"ask A→B resolved the A↔B pair"
|
||||
);
|
||||
// The live B session is bound to the A↔B thread (session keyed by conversation).
|
||||
assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session");
|
||||
assert!(
|
||||
a_to_b.session.is_live(),
|
||||
"A↔B thread bound to a live session"
|
||||
);
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(2), "B done"))
|
||||
@ -1964,21 +2028,30 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
|
||||
// A→B in flight (held — B never replies during the test): edge A→B is posted.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let _ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
.await
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
|
||||
// Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast.
|
||||
let err = timeout(
|
||||
TEST_GUARD,
|
||||
fx.service
|
||||
.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))),
|
||||
fx.service.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cycle ask must return fast, never deadlock")
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"re-entrant cycle is typed INVALID: {err:?}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"),
|
||||
"explicit cycle message: {err}"
|
||||
@ -1999,8 +2072,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// A→B completes.
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask_ab = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
|
||||
.await
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
|
||||
fx.service
|
||||
@ -2012,8 +2088,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
// Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending).
|
||||
let svc2 = Arc::clone(&fx.service);
|
||||
let ask_ba = tokio::spawn(async move {
|
||||
svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")))
|
||||
.await
|
||||
svc2.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.service
|
||||
@ -2021,7 +2100,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
|
||||
.await
|
||||
.expect("reply ok");
|
||||
let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("A ok"),
|
||||
"reverse delegation allowed"
|
||||
);
|
||||
}
|
||||
|
||||
/// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent
|
||||
@ -2064,7 +2147,11 @@ async fn reply_correlates_by_ticket() {
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"INVALID",
|
||||
"unknown ticket id ⇒ typed INVALID: {err:?}"
|
||||
);
|
||||
let t2 = fx.mailbox.ticket_ids(&aid(2))[0];
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2"))
|
||||
@ -2129,7 +2216,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"closed-channel ask is a typed error: {err:?}"
|
||||
);
|
||||
// Queue freed, and the target B is still live (never killed by the failed ask).
|
||||
assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(2)),
|
||||
0,
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
Some(sid(802)),
|
||||
@ -2232,8 +2323,11 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
// A delegation A→B blocks in B's FIFO (it awaits a reply).
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move {
|
||||
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg")))
|
||||
.await
|
||||
svc.dispatch(
|
||||
&project(),
|
||||
cmd(&ask_from_agent_json(aid(10), "architect", "deleg")),
|
||||
)
|
||||
.await
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
@ -2256,7 +2350,8 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
);
|
||||
|
||||
// Unblock the delegation so the spawned task ends cleanly.
|
||||
fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
fx.mailbox
|
||||
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
let _ = timeout(TEST_GUARD, ask).await;
|
||||
}
|
||||
|
||||
@ -2275,11 +2370,7 @@ async fn interrupt_preempts_without_enqueue_or_resolve() {
|
||||
.expect("interrupt succeeds");
|
||||
|
||||
assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once");
|
||||
assert_eq!(
|
||||
fx.mailbox.pending(&aid(1)),
|
||||
0,
|
||||
"interrupt enqueues nothing"
|
||||
);
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "interrupt enqueues nothing");
|
||||
}
|
||||
|
||||
/// A human submit to an unknown agent id is a typed NotFound (never a panic).
|
||||
@ -2304,7 +2395,10 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}");
|
||||
assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent");
|
||||
assert!(
|
||||
fx.mediator.preempts().is_empty(),
|
||||
"no preempt on unknown agent"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2323,13 +2417,13 @@ async fn interrupt_unknown_agent_is_not_found() {
|
||||
// in `conversation_record.rs`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
use domain::{
|
||||
ConversationLog, ConversationTurn as DomainTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
TurnId, TurnRole,
|
||||
};
|
||||
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
|
||||
use domain::ports::Clock;
|
||||
use domain::InputSource;
|
||||
|
||||
/// A deterministic [`Clock`]: every persisted turn is stamped with this constant.
|
||||
struct FixedClock(i64);
|
||||
@ -2367,7 +2461,9 @@ impl ConversationLog for RecordingLog {
|
||||
turn: DomainTurn,
|
||||
) -> Result<(), StoreError> {
|
||||
if self.fail_append {
|
||||
return Err(StoreError::Io("recording log: forced append failure".to_owned()));
|
||||
return Err(StoreError::Io(
|
||||
"recording log: forced append failure".to_owned(),
|
||||
));
|
||||
}
|
||||
self.appends.lock().unwrap().push(turn);
|
||||
Ok(())
|
||||
@ -2399,11 +2495,7 @@ impl HandoffStore for NoopHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().get(&conversation).cloned())
|
||||
}
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().insert(conversation, handoff);
|
||||
Ok(())
|
||||
}
|
||||
@ -2419,7 +2511,10 @@ impl HandoffSummarizer for ConcatSummarizer {
|
||||
for t in new_turns {
|
||||
summary.push_str(&t.text);
|
||||
}
|
||||
let up_to = new_turns.last().map(|t| t.id).expect("at least one new turn");
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("at least one new turn");
|
||||
Handoff::new(summary, up_to, None)
|
||||
}
|
||||
}
|
||||
@ -2564,7 +2659,11 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
|
||||
|
||||
let appends = log.appends();
|
||||
assert_eq!(appends.len(), 2, "exactly two turns persisted (Prompt + Response)");
|
||||
assert_eq!(
|
||||
appends.len(),
|
||||
2,
|
||||
"exactly two turns persisted (Prompt + Response)"
|
||||
);
|
||||
|
||||
let prompt = &appends[0];
|
||||
let response = &appends[1];
|
||||
@ -2575,12 +2674,26 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
|
||||
);
|
||||
// Order + role.
|
||||
assert_eq!(prompt.role, TurnRole::Prompt, "first turn is the Prompt");
|
||||
assert_eq!(response.role, TurnRole::Response, "second turn is the Response");
|
||||
assert_eq!(
|
||||
response.role,
|
||||
TurnRole::Response,
|
||||
"second turn is the Response"
|
||||
);
|
||||
// Text: task then result.
|
||||
assert_eq!(prompt.text, "Analyse §17", "prompt text is the delegated task");
|
||||
assert_eq!(response.text, "the §17 answer", "response text is the reply result");
|
||||
assert_eq!(
|
||||
prompt.text, "Analyse §17",
|
||||
"prompt text is the delegated task"
|
||||
);
|
||||
assert_eq!(
|
||||
response.text, "the §17 answer",
|
||||
"response text is the reply result"
|
||||
);
|
||||
// Source: Human prompt (no agent requester), target-sourced response.
|
||||
assert_eq!(prompt.source, InputSource::Human, "User ask ⇒ Human prompt source");
|
||||
assert_eq!(
|
||||
prompt.source,
|
||||
InputSource::Human,
|
||||
"User ask ⇒ Human prompt source"
|
||||
);
|
||||
assert_eq!(
|
||||
response.source,
|
||||
InputSource::agent(aid(1)),
|
||||
@ -2603,8 +2716,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
Arc::new(ConcatSummarizer) as Arc<dyn HandoffSummarizer>,
|
||||
));
|
||||
let provider = Arc::new(SharedRecordProvider(record)) as Arc<dyn RecordTurnProvider>;
|
||||
let fx =
|
||||
ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0);
|
||||
let fx = ask_fixture_with_record(
|
||||
FakeContexts::with_agent(&architect, "# persona"),
|
||||
provider,
|
||||
0,
|
||||
);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
// Requester is agent A (id 7). The orchestrator threads `requester` from the
|
||||
@ -2625,7 +2741,10 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
// assert both turns landed on it.
|
||||
let convs = TestConversations::new();
|
||||
let expected = convs
|
||||
.resolve(ConversationParty::agent(a), ConversationParty::agent(aid(1)))
|
||||
.resolve(
|
||||
ConversationParty::agent(a),
|
||||
ConversationParty::agent(aid(1)),
|
||||
)
|
||||
.id;
|
||||
// (Resolution is deterministic per pair within one registry; we instead assert the
|
||||
// turns share a thread and the prompt source carries A — the load-bearing facts.)
|
||||
@ -2655,7 +2774,11 @@ async fn p6b_no_provider_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds without persistence");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds without persistence"
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider wired but returns `None` for the root ⇒ ask still succeeds (best-effort
|
||||
@ -2668,7 +2791,11 @@ async fn p6b_provider_returns_none_is_silent_no_op() {
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
|
||||
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds when provider declines");
|
||||
assert_eq!(
|
||||
out.reply.as_deref(),
|
||||
Some("ok"),
|
||||
"ask succeeds when provider declines"
|
||||
);
|
||||
}
|
||||
|
||||
/// `RecordTurn` built on a log whose `append` always fails ⇒ persistence errors are
|
||||
@ -2693,5 +2820,8 @@ async fn p6b_failing_record_does_not_degrade_ask() {
|
||||
"a failing append must not turn a successful delegation into an error"
|
||||
);
|
||||
// The failing log recorded nothing (every append errored).
|
||||
assert!(failing_log.appends().is_empty(), "no turns stored when append fails");
|
||||
assert!(
|
||||
failing_log.appends().is_empty(),
|
||||
"no turns stored when append fails"
|
||||
);
|
||||
}
|
||||
|
||||
@ -218,16 +218,26 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
||||
assert!(out.is_first_run);
|
||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
||||
// drivable) profiles — Claude + Codex — not the full 4-profile catalogue.
|
||||
assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded");
|
||||
assert_eq!(
|
||||
out.reference_profiles.len(),
|
||||
2,
|
||||
"selectable catalogue seeded"
|
||||
);
|
||||
let commands: Vec<&str> = out
|
||||
.reference_profiles
|
||||
.iter()
|
||||
.map(|p| p.command.as_str())
|
||||
.collect();
|
||||
assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered");
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec!["claude", "codex"],
|
||||
"only Claude/Codex offered"
|
||||
);
|
||||
// Every seeded profile is selectable (the gate the menu relies on).
|
||||
assert!(
|
||||
out.reference_profiles.iter().all(AgentProfile::is_selectable),
|
||||
out.reference_profiles
|
||||
.iter()
|
||||
.all(AgentProfile::is_selectable),
|
||||
"seeded profiles must all be selectable"
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,10 +99,14 @@ fn delta(t: &str) -> ReplyEvent {
|
||||
ReplyEvent::TextDelta { text: t.to_owned() }
|
||||
}
|
||||
fn tool(l: &str) -> ReplyEvent {
|
||||
ReplyEvent::ToolActivity { label: l.to_owned() }
|
||||
ReplyEvent::ToolActivity {
|
||||
label: l.to_owned(),
|
||||
}
|
||||
}
|
||||
fn final_(c: &str) -> ReplyEvent {
|
||||
ReplyEvent::Final { content: c.to_owned() }
|
||||
ReplyEvent::Final {
|
||||
content: c.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -174,8 +178,9 @@ async fn empty_stream_is_io_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_decode_error_is_propagated() {
|
||||
let session =
|
||||
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
|
||||
"bad json".to_owned(),
|
||||
)));
|
||||
let out = send_blocking(&session, "x", None).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
}
|
||||
|
||||
@ -570,10 +570,10 @@ impl FakeProviderSessionStore {
|
||||
/// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
|
||||
/// resumable for that pair (mirrors what a previous P8b launch would have stored).
|
||||
fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
|
||||
self.entries.lock().unwrap().insert(
|
||||
(conversation, provider.to_owned()),
|
||||
resumable_id.to_owned(),
|
||||
);
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((conversation, provider.to_owned()), resumable_id.to_owned());
|
||||
}
|
||||
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||
@ -606,10 +606,10 @@ impl ProviderSessionStore for FakeProviderSessionStore {
|
||||
if self.fail_set {
|
||||
return Err(StoreError::Io("forced set failure".to_owned()));
|
||||
}
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((conversation, provider_id.to_owned()), resumable_id.to_owned());
|
||||
self.entries.lock().unwrap().insert(
|
||||
(conversation, provider_id.to_owned()),
|
||||
resumable_id.to_owned(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -862,14 +862,21 @@ async fn non_structured_profile_takes_pty_path_unchanged() {
|
||||
|
||||
// PTY spawn appelé, factory jamais sollicitée.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "pty path spawns");
|
||||
assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"factory not called for pty profile"
|
||||
);
|
||||
|
||||
// Session côté registre PTY, rien côté structuré.
|
||||
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777)));
|
||||
assert!(f.structured.session_for_agent(&f.agent.id).is_none());
|
||||
|
||||
// output.structured = None ; session PTY classique.
|
||||
assert!(out.structured.is_none(), "no structured descriptor on pty path");
|
||||
assert!(
|
||||
out.structured.is_none(),
|
||||
"no structured descriptor on pty path"
|
||||
);
|
||||
assert_eq!(out.session.id, sid(777));
|
||||
}
|
||||
|
||||
@ -911,7 +918,11 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
|
||||
"no second factory.start on a refused launch"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.node_for_agent(&f.agent.id),
|
||||
Some(host),
|
||||
@ -973,7 +984,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
|
||||
"no second factory.start on explicit reattach"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "still a single live structured session");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
|
||||
let desc = out.structured.expect("descriptor on rebind");
|
||||
assert_eq!(desc.session_id, sid(500), "same live session id");
|
||||
@ -1125,7 +1140,11 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
f.pty.kills().is_empty(),
|
||||
"no PTY kill for a structured live session"
|
||||
);
|
||||
assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)");
|
||||
assert_eq!(
|
||||
f.pty.spawn_count(),
|
||||
0,
|
||||
"no PTY spawn (target is structured)"
|
||||
);
|
||||
|
||||
// Relance via la factory : un nouveau start (donc total 2).
|
||||
assert_eq!(
|
||||
@ -1137,12 +1156,25 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
// `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession)
|
||||
// — on vérifie donc le registre structuré + le snapshot.
|
||||
// Seed consumed id 600; the relaunch's session is the factory's next id (601).
|
||||
let relaunched = out.relaunched.expect("a live structured agent is relaunched");
|
||||
assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
let relaunched = out
|
||||
.relaunched
|
||||
.expect("a live structured agent is relaunched");
|
||||
assert_eq!(
|
||||
relaunched.id,
|
||||
sid(601),
|
||||
"relaunch adopts the new structured id"
|
||||
);
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601)));
|
||||
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host));
|
||||
assert_eq!(f.structured.len(), 1, "single live structured session after swap");
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
"single live structured session after swap"
|
||||
);
|
||||
// Manifeste muté vers le nouveau profil.
|
||||
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
||||
}
|
||||
@ -1169,12 +1201,19 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
|
||||
// A1 d'origine : le PTY vivant est tué.
|
||||
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
|
||||
// Aucune session structurée n'a été créée ni shutdown.
|
||||
assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap");
|
||||
assert_eq!(
|
||||
f.factory.start_count(),
|
||||
0,
|
||||
"no structured start on pty swap"
|
||||
);
|
||||
assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown");
|
||||
// Relance PTY : un spawn, même cellule.
|
||||
assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once");
|
||||
let relaunched = out.relaunched.expect("a live agent is relaunched");
|
||||
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
|
||||
assert_eq!(
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777));
|
||||
// The relaunched session lives in the PTY registry, not the structured one.
|
||||
assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777)));
|
||||
@ -1321,7 +1360,8 @@ fn launch_fixture_p8b(
|
||||
)
|
||||
.with_structured(Arc::new(factory), Arc::clone(&structured));
|
||||
if let Some(store) = store {
|
||||
let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
let provider =
|
||||
Arc::new(FakeProviderSessionProvider(store)) as Arc<dyn ProviderSessionProvider>;
|
||||
launch = launch.with_provider_session_provider(provider);
|
||||
}
|
||||
(Arc::new(launch), agent)
|
||||
@ -1335,8 +1375,11 @@ fn launch_fixture_p8b(
|
||||
async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// La cellule porte un id de paire UUID valide : c'est lui qui sert de clé.
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
@ -1406,7 +1449,10 @@ async fn p8b_no_provider_wired_writes_nothing_launch_ok() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("launch ok without provider");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without provider");
|
||||
|
||||
assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written");
|
||||
}
|
||||
@ -1418,14 +1464,20 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Factory avec conversation_id None ⇒ session.conversation_id() == None.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("launch ok without engine id");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("launch ok without engine id");
|
||||
|
||||
assert_eq!(
|
||||
store.len(),
|
||||
@ -1441,8 +1493,11 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() {
|
||||
async fn p8b_store_set_error_does_not_fail_launch() {
|
||||
let store = Arc::new(FakeProviderSessionStore::failing());
|
||||
let factory = FakeFactory::new(500, Some("engine-xyz"));
|
||||
let (launch, agent) =
|
||||
launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent) = launch_fixture_p8b(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
@ -1498,8 +1553,11 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
// Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Pré-remplit le store : (pair, "claude") → "engine-x".
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
@ -1509,7 +1567,10 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured launch resumes");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured launch resumes");
|
||||
|
||||
// Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
|
||||
let starts = observed.starts();
|
||||
@ -1552,7 +1613,10 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(pair.to_string());
|
||||
|
||||
launch.execute(input).await.expect("structured codex launch");
|
||||
launch
|
||||
.execute(input)
|
||||
.await
|
||||
.expect("structured codex launch");
|
||||
|
||||
let starts = observed.starts();
|
||||
assert_eq!(starts.len(), 1);
|
||||
@ -1572,8 +1636,11 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||
async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
// Engine id rangé sous une AUTRE clé provider.
|
||||
@ -1600,8 +1667,11 @@ async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||
async fn p8c_structured_store_empty_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||
let mut input = launch_input(agent.id);
|
||||
@ -1624,8 +1694,11 @@ async fn p8c_structured_store_empty_resolves_to_none() {
|
||||
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
// Cellule neuve : conversation_id = None.
|
||||
launch
|
||||
@ -1649,8 +1722,11 @@ async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
|
||||
let store = Arc::new(FakeProviderSessionStore::new());
|
||||
let factory = FakeFactory::new(500, None);
|
||||
let (launch, agent, observed) =
|
||||
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||
let (launch, agent, observed) = launch_fixture_p8c(
|
||||
structured_profile(pid(9)),
|
||||
factory,
|
||||
Some(Arc::clone(&store)),
|
||||
);
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some("not-a-uuid".to_owned());
|
||||
|
||||
@ -21,12 +21,8 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, PtyHandle, ReplyStream,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -121,10 +117,7 @@ fn structured_live_agents_lists_triples() {
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
(aid(10), nid(100), sid(1)),
|
||||
(aid(20), nid(200), sid(2)),
|
||||
]
|
||||
vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user