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

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

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

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

View File

@ -5,31 +5,29 @@
//! `Arc<dyn Port>` into the use cases (ARCHITECTURE §1.1, §10). The use cases
//! are then exposed through `tauri::State<AppState>` to the command handlers.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use application::{
AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab,
CloseTerminal,
ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory,
CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout,
DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines,
DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills,
ListTemplates,
LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal,
ResolveMemoryLinks,
SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
FirstRunState, GetMemory, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog,
GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents,
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory,
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout,
ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout,
SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory,
UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
@ -37,19 +35,25 @@ use domain::ports::{
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore,
TemplateStore,
};
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
};
use domain::remote::RemoteKind;
use domain::{AgentId, DomainEvent, EmbedderProfile, Project, ProjectId};
use serde_json::{json, Map, Value};
use uuid::Uuid;
use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
SystemClock, TokioBroadcastEventBus,
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock,
SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
};
use crate::chat::ChatBridge;
@ -646,14 +650,15 @@ impl AppState {
// porte une conversation et qu'un handoff existe (`<root>/.ideai/conversations/`),
// son résumé est réinjecté dans le convention file. Best-effort, additif :
// un handoff absent/illisible ⇒ lancement normal sans section.
.with_handoff_provider(Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>)
.with_handoff_provider(
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>
)
// Resumable moteur par provider (lot P8b) : après un lancement structuré
// exposant un id de session moteur, rangé sous la clé de paire dans
// `<root>/.ideai/conversations/providers.json`. Best-effort, additif : une
// écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture.
.with_provider_session_provider(
Arc::new(AppProviderSessionProvider) as Arc<dyn application::ProviderSessionProvider>,
),
.with_provider_session_provider(Arc::new(AppProviderSessionProvider)
as Arc<dyn application::ProviderSessionProvider>),
);
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
@ -821,8 +826,7 @@ impl AppState {
// cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
let mailbox =
Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
let mailbox = Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
let input_mediator = Arc::new(
MediatedInbox::with_pty(
Arc::clone(&inmemory_mailbox),
@ -835,8 +839,8 @@ impl AppState {
) as Arc<dyn domain::input::InputMediator>;
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
// vivante keyée par conversation (lève l'ambiguïté session/agent).
let conversation_registry =
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
let conversation_registry = Arc::new(InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>;
let orchestrator_service = Arc::new(
OrchestratorService::new(
Arc::clone(&create_agent),
@ -1056,6 +1060,16 @@ impl AppState {
.unwrap_or_default()
}
/// Repairs the persisted Claude run-dir artefacts of `project` so a freshly
/// launched AppImage does not inherit stale MCP/settings state from older runs.
///
/// Best-effort and local-only: remote SSH/WSL projects are skipped because this
/// migration rewrites the local run-dir files the AppImage owns. Launch-time
/// repair still remains available on the normal activation path.
pub async fn reconcile_claude_run_dirs(&self, project: &Project) {
self.migrate_claude_run_dirs(project).await;
}
/// Stops and removes the orchestrator watcher for `project_id`, if any.
/// Called from `close_project` so a closed project stops consuming requests.
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
@ -1078,6 +1092,556 @@ impl AppState {
handle.stop();
}
}
/// Best-effort migration of persisted Claude run dirs at project-open time:
/// repairs stale `.mcp.json` declarations and missing
/// `enabledMcpjsonServers` entries in `.claude/settings.local.json`.
pub async fn migrate_claude_run_dirs(&self, project: &Project) {
if project.remote.kind() != RemoteKind::Local {
return;
}
let agents = match self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
{
Ok(output) => output.agents,
Err(_) => return,
};
let profiles = match self.list_profiles.execute().await {
Ok(output) => output.profiles,
Err(_) => return,
};
let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect();
for agent in agents {
let Some(profile) = profile_by_id.get(&agent.profile_id) else {
continue;
};
if !is_claude_mcp_profile(profile) {
continue;
}
let _ = migrate_claude_run_dir(project, &agent.id, profile).await;
}
}
}
async fn migrate_claude_run_dir(
project: &Project,
agent_id: &AgentId,
profile: &AgentProfile,
) -> Result<(), std::io::Error> {
let run_dir = project
.root
.as_str()
.trim_end_matches(['/', '\\'])
.to_owned()
+ &format!("/.ideai/run/{agent_id}");
let run_dir_path = Path::new(&run_dir);
if tokio::fs::metadata(run_dir_path).await.is_err() {
return Ok(());
}
migrate_claude_settings(run_dir_path, project.root.as_str()).await?;
let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
});
migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?;
Ok(())
}
async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> {
let claude_dir = run_dir.join(".claude");
let settings_path = claude_dir.join("settings.local.json");
let next = match tokio::fs::read_to_string(&settings_path).await {
Ok(existing) => merge_claude_settings_json(&existing, project_root),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Some(claude_settings_seed_value(project_root))
}
Err(err) => return Err(err),
};
let Some(next) = next else {
return Ok(());
};
tokio::fs::create_dir_all(&claude_dir).await?;
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
body.push('\n');
write_atomically(&settings_path, &body)
}
async fn migrate_claude_mcp_config(
run_dir: &Path,
profile: &AgentProfile,
runtime: Option<&McpRuntime>,
) -> Result<(), std::io::Error> {
let Some(target) = claude_mcp_config_target(profile) else {
return Ok(());
};
let Some(runtime) = runtime else {
return Ok(());
};
let mcp_path = run_dir.join(target);
let desired_server = mcp_server_entry(profile, Some(runtime));
let next = match tokio::fs::read_to_string(&mcp_path).await {
Ok(existing) => merge_mcp_json(&existing, desired_server),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Some(wrap_idea_mcp_server(desired_server))
}
Err(err) => return Err(err),
};
let Some(next) = next else {
return Ok(());
};
if let Some(parent) = mcp_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?;
body.push('\n');
write_atomically(&mcp_path, &body)
}
fn is_claude_mcp_profile(profile: &AgentProfile) -> bool {
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude)
|| matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
);
is_claude && claude_mcp_config_target(profile).is_some()
}
fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
match profile.mcp.as_ref().map(|mcp| &mcp.config) {
Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()),
_ => None,
}
}
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
let mut doc = match serde_json::from_str::<Value>(existing) {
Ok(Value::Object(map)) => Value::Object(map),
Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)),
};
let before = doc.clone();
let root = doc.as_object_mut().expect("object preserved above");
let permissions = root
.entry("permissions".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if !permissions.is_object() {
*permissions = Value::Object(Map::new());
}
let permissions = permissions
.as_object_mut()
.expect("permissions forced to object");
permissions.insert(
"defaultMode".to_owned(),
Value::String("bypassPermissions".to_owned()),
);
permissions.insert(
"additionalDirectories".to_owned(),
Value::Array(merge_string_array(
permissions.get("additionalDirectories"),
[project_root],
)),
);
permissions.insert(
"allow".to_owned(),
Value::Array(merge_string_array(
permissions.get("allow"),
["Read", "Edit", "Write", "Bash"],
)),
);
permissions.insert(
"deny".to_owned(),
Value::Array(merge_string_array(
permissions.get("deny"),
[
"Bash(sudo *)",
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/)",
"Bash(rm -rf ~/*)",
"Bash(rm -rf $HOME*)",
"Bash(mkfs*)",
"Bash(dd if=*)",
"Bash(shutdown*)",
"Bash(reboot*)",
],
)),
);
root.insert(
"skipDangerousModePermissionPrompt".to_owned(),
Value::Bool(true),
);
root.insert(
"enabledMcpjsonServers".to_owned(),
Value::Array(merge_string_array(
root.get("enabledMcpjsonServers"),
["idea"],
)),
);
let sandbox = root
.entry("sandbox".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if !sandbox.is_object() {
*sandbox = Value::Object(Map::new());
}
let sandbox = sandbox.as_object_mut().expect("sandbox forced to object");
sandbox.insert("enabled".to_owned(), Value::Bool(false));
if doc != before {
Some(doc)
} else {
None
}
}
fn merge_mcp_json(existing: &str, desired_server: Value) -> Option<Value> {
let mut doc = match serde_json::from_str::<Value>(existing) {
Ok(Value::Object(map)) => map,
Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)),
};
let mcp_servers = doc
.entry("mcpServers".to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if !mcp_servers.is_object() {
*mcp_servers = Value::Object(Map::new());
}
let changed = mcp_servers.get("idea") != Some(&desired_server);
if !changed {
return None;
}
match mcp_servers {
Value::Object(servers) => {
servers.insert("idea".to_owned(), desired_server);
Some(Value::Object(doc))
}
_ => None,
}
}
fn wrap_idea_mcp_server(server: Value) -> Value {
let mut servers = Map::new();
servers.insert("idea".to_owned(), server);
let mut root = Map::new();
root.insert("mcpServers".to_owned(), Value::Object(servers));
Value::Object(root)
}
fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value {
let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) {
Some(McpTransport::Socket) => "socket",
Some(McpTransport::Stdio) | None => "stdio",
};
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
Value::String("mcp-server".to_owned()),
Value::String("--endpoint".to_owned()),
Value::String(rt.endpoint.clone()),
Value::String("--project".to_owned()),
Value::String(rt.project_id.clone()),
Value::String("--requester".to_owned()),
Value::String(rt.requester.clone()),
],
),
None => (
"idea".to_owned(),
vec![Value::String("mcp-server".to_owned())],
),
};
let mut server = Map::new();
server.insert("command".to_owned(), Value::String(command));
server.insert("args".to_owned(), Value::Array(args));
server.insert("transport".to_owned(), Value::String(transport.to_owned()));
Value::Object(server)
}
fn claude_settings_seed_value(project_root: &str) -> Value {
json!({
"permissions": {
"defaultMode": "bypassPermissions",
"additionalDirectories": [project_root],
"allow": ["Read", "Edit", "Write", "Bash"],
"deny": [
"Bash(sudo *)",
"Bash(rm -rf /)",
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf ~/)",
"Bash(rm -rf ~/*)",
"Bash(rm -rf $HOME*)",
"Bash(mkfs*)",
"Bash(dd if=*)",
"Bash(shutdown*)",
"Bash(reboot*)"
]
},
"skipDangerousModePermissionPrompt": true,
"enabledMcpjsonServers": ["idea"],
"sandbox": { "enabled": false }
})
}
fn merge_string_array<'a>(
existing: Option<&Value>,
required: impl IntoIterator<Item = &'a str>,
) -> Vec<Value> {
let mut seen = HashSet::<String>::new();
let mut out = Vec::new();
if let Some(existing) = existing.and_then(Value::as_array) {
for item in existing.iter().filter_map(Value::as_str) {
if seen.insert(item.to_owned()) {
out.push(Value::String(item.to_owned()));
}
}
}
for item in required {
if seen.insert(item.to_owned()) {
out.push(Value::String(item.to_owned()));
}
}
out
}
fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_name = path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from("tmp"));
let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4());
let tmp_path = path.with_file_name(tmp_name);
std::fs::write(&tmp_path, content.as_bytes())?;
#[cfg(windows)]
if path.exists() {
let _ = std::fs::remove_file(path);
}
std::fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod run_dir_migration_tests {
use super::{
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
};
use application::McpRuntimeProvider;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use serde_json::json;
use uuid::Uuid;
fn claude_profile() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
None,
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))
}
fn runtime(agent_id: AgentId) -> application::McpRuntime {
application::McpRuntime {
exe: "/opt/IdeA.AppImage".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: ProjectId::from_uuid(Uuid::from_u128(1234))
.as_uuid()
.simple()
.to_string(),
requester: agent_id.to_string(),
}
}
#[test]
fn merge_claude_settings_adds_idea_and_preserves_existing_entries() {
let merged = merge_claude_settings_json(
r#"{
"permissions": {
"additionalDirectories": ["/tmp/custom"],
"allow": ["Read"],
"deny": ["Bash(custom)"]
},
"enabledMcpjsonServers": ["other"],
"extra": true
}"#,
"/home/me/proj",
)
.unwrap();
let parsed = merged;
assert_eq!(parsed["extra"], json!(true));
assert_eq!(
parsed["permissions"]["defaultMode"],
json!("bypassPermissions")
);
assert!(parsed["permissions"]["additionalDirectories"]
.as_array()
.unwrap()
.contains(&json!("/tmp/custom")));
assert!(parsed["permissions"]["additionalDirectories"]
.as_array()
.unwrap()
.contains(&json!("/home/me/proj")));
assert!(parsed["enabledMcpjsonServers"]
.as_array()
.unwrap()
.contains(&json!("other")));
assert!(parsed["enabledMcpjsonServers"]
.as_array()
.unwrap()
.contains(&json!("idea")));
}
#[test]
fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(77));
let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id)));
let merged = merge_mcp_json(
r#"{
"mcpServers": {
"idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" },
"other": { "command": "keep-me", "args": [] }
}
}"#,
desired.clone(),
)
.unwrap();
let parsed = merged;
assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me"));
assert_eq!(parsed["mcpServers"]["idea"], desired);
}
#[test]
fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(88));
let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4()));
let project_root = temp.join("project");
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
std::fs::create_dir_all(run_dir.join(".claude")).unwrap();
std::fs::write(
run_dir.join(".claude/settings.local.json"),
r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#,
)
.unwrap();
std::fs::write(
run_dir.join(".mcp.json"),
r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#,
)
.unwrap();
let project = domain::Project::new(
ProjectId::from_uuid(Uuid::from_u128(1234)),
"demo",
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
domain::remote::RemoteRef::local(),
1,
)
.unwrap();
let profile = claude_profile();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
migrate_claude_run_dir(&project, &agent_id, &profile)
.await
.unwrap();
});
let settings: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(),
)
.unwrap();
assert!(settings["enabledMcpjsonServers"]
.as_array()
.unwrap()
.contains(&json!("idea")));
assert!(settings["permissions"]["additionalDirectories"]
.as_array()
.unwrap()
.contains(&json!(project.root.as_str())));
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap())
.unwrap();
let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider
.runtime_for(&project, agent_id)
.unwrap();
assert_eq!(
mcp["mcpServers"]["idea"],
mcp_server_entry(&claude_profile(), Some(&expected_runtime))
);
let _ = std::fs::remove_dir_all(temp);
}
#[test]
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
assert!(is_claude_mcp_profile(&claude_profile()));
}
#[test]
fn invalid_settings_fall_back_to_seed() {
let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap();
assert_eq!(merged, claude_settings_seed_value("/home/me/proj"));
}
#[test]
fn malformed_typed_settings_are_repaired_without_panicking() {
let merged = merge_claude_settings_json(
r#"{
"permissions": [],
"sandbox": false,
"enabledMcpjsonServers": "idea"
}"#,
"/home/me/proj",
)
.unwrap();
assert_eq!(
merged["permissions"]["defaultMode"],
json!("bypassPermissions")
);
assert_eq!(merged["sandbox"]["enabled"], json!(false));
assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"]));
}
}
/// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the
@ -1113,10 +1677,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
}
}
}
let name = endpoint
.as_cli_arg()
.to_fs_name::<GenericFilePath>()
.ok()?;
let name = endpoint.as_cli_arg().to_fs_name::<GenericFilePath>().ok()?;
ListenerOptions::new()
.name(name)
// Reclaim (unlink) on drop so a clean close leaves no socket behind.
@ -1808,7 +2369,10 @@ mod mcp_serve_peer_tests {
.write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes())
.await
.unwrap();
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
client
.write_all(tools_list_line(1).as_bytes())
.await
.unwrap();
client.flush().await.unwrap();
let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client))
@ -1833,7 +2397,10 @@ mod mcp_serve_peer_tests {
"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(),
@ -1888,7 +2455,11 @@ mod mcp_serve_peer_tests {
.iter()
.filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. }))
.collect();
assert_eq!(processed.len(), 1, "exactly one processed event; got {events:?}");
assert_eq!(
processed.len(),
1,
"exactly one processed event; got {events:?}"
);
match processed[0] {
DomainEvent::OrchestratorRequestProcessed {
requester_id,
@ -1976,7 +2547,11 @@ mod mcp_serve_peer_tests {
let resp = read_one_response(&mut client)
.await
.expect("the glued tools/list must still be served");
assert_eq!(resp["id"], json!(7), "the buffered request id must come through");
assert_eq!(
resp["id"],
json!(7),
"the buffered request id must come through"
);
assert!(
resp["result"]["tools"].is_array(),
"buffered request produced a real tools/list result; got {resp}"
@ -2053,7 +2628,10 @@ mod mcp_serve_peer_tests {
.write_all(handshake_line("", "agent-1").as_bytes())
.await
.unwrap();
client.write_all(tools_list_line(1).as_bytes()).await.unwrap();
client
.write_all(tools_list_line(1).as_bytes())
.await
.unwrap();
client.flush().await.unwrap();
let resp = read_one_response(&mut client)
@ -2091,7 +2669,10 @@ mod mcp_serve_peer_tests {
.write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes())
.await
.unwrap();
client_b.write_all(tools_list_line(2).as_bytes()).await.unwrap();
client_b
.write_all(tools_list_line(2).as_bytes())
.await
.unwrap();
client_b.flush().await.unwrap();
let resp = read_one_response(&mut client_b)
@ -2304,7 +2885,7 @@ mod mcp_e2e_loopback_tests {
use async_trait::async_trait;
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
use interprocess::local_socket::traits::tokio::Stream as _;
use tokio::io::{AsyncWriteExt, BufReader, AsyncBufReadExt};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use uuid::Uuid;
use application::{
@ -2683,8 +3264,8 @@ mod mcp_e2e_loopback_tests {
Arc::new(SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>;
let conversations =
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
let conversations = Arc::new(InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>;
let service = OrchestratorService::new(
create,
launch,
@ -2743,12 +3324,8 @@ mod mcp_e2e_loopback_tests {
if let Some(publish) = events {
server = server.with_events(publish);
}
let handle = McpServerHandle::start(
server,
endpoint.clone(),
listener,
project_id_arg(project),
);
let handle =
McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project));
(handle, endpoint)
}
@ -2790,7 +3367,7 @@ mod mcp_e2e_loopback_tests {
{
let mut line = String::new();
match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await {
Ok(Ok(0)) => None, // EOF before a full line
Ok(Ok(0)) => None, // EOF before a full line
Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(),
Ok(Err(_)) => None,
Err(_) => None, // GARDE-FOU: timed out waiting for a reply
@ -2859,7 +3436,11 @@ mod mcp_e2e_loopback_tests {
let agent_id = contexts.seed_agent("architect");
let (service, sessions, mailbox) = build_service(contexts);
// 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 proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2913,7 +3494,11 @@ mod mcp_e2e_loopback_tests {
let reply_resp = read_one_response(&mut reader_b)
.await
.expect("a reply ack line");
assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}");
assert_eq!(
reply_resp["result"]["isError"],
json!(false),
"got {reply_resp}"
);
// The asker now receives its inline result.
let resp = read_one_response(&mut reader_a)
@ -2958,9 +3543,7 @@ mod mcp_e2e_loopback_tests {
.await
.unwrap();
write_half
.write_all(
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
)
.write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes())
.await
.unwrap();
write_half.flush().await.unwrap();
@ -3031,20 +3614,23 @@ mod mcp_e2e_loopback_tests {
.await
.unwrap();
// A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop.
write_half
.write_all(b"{ this is not json\n")
.await
.unwrap();
write_half.write_all(b"{ this is not json\n").await.unwrap();
write_half.flush().await.unwrap();
let resp = read_one_response(&mut reader)
.await
.expect("a parse error still owes a response");
let error = &resp["error"];
assert!(!error.is_null(), "malformed input must yield a JSON-RPC error: {resp}");
assert!(
!error.is_null(),
"malformed input must yield a JSON-RPC error: {resp}"
);
// JSON-RPC mandates a null id when the request could not be correlated.
assert_eq!(resp["id"], Value::Null, "got {resp}");
assert!(resp["result"].is_null(), "no result on parse error; got {resp}");
assert!(
resp["result"].is_null(),
"no result on parse error; got {resp}"
);
// The server survived: a subsequent valid call still answers.
write_half
@ -3200,7 +3786,9 @@ mod bind_endpoint_d1_tests {
use std::os::unix::net::UnixListener;
let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001)));
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
let path = ep
.socket_path()
.expect("unix endpoint exposes a socket path");
// Clean any leftover from a previous run of this very test.
let _ = std::fs::remove_file(&path);
@ -3214,7 +3802,10 @@ mod bind_endpoint_d1_tests {
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
drop(corpse);
}
assert!(path.exists(), "corpse socket inode remains (no live listener)");
assert!(
path.exists(),
"corpse socket inode remains (no live listener)"
);
// 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT
// fail with EADDRINUSE.
@ -3239,4 +3830,3 @@ mod bind_endpoint_d1_tests {
);
}
}