Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage sécurisé des secrets (SecretStore + adapter infrastructure), et les use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition root. Couvre le fix B1 et les tests de régression demandés par QA. cargo build --workspace propre, cargo test --workspace -- --test-threads=1 intégralement vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
7.5 KiB
Rust
249 lines
7.5 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use application::McpRuntime;
|
|
use async_trait::async_trait;
|
|
use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer};
|
|
use domain::profile::{McpCapability, McpConfigStrategy, McpTransport};
|
|
use domain::{
|
|
AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan,
|
|
FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc,
|
|
PreparedContext, ProfileId, Project, ProjectId, ProjectPath, RemotePath, RuntimeError,
|
|
SpawnSpec,
|
|
};
|
|
use infrastructure::{
|
|
FsAssistantContextStore, FsSecretStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("idea-assistant-context-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
|
|
fn app_data_dir(&self) -> String {
|
|
self.0.join("app-data").to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn project_root(&self) -> ProjectPath {
|
|
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn project(root: ProjectPath) -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
|
"demo",
|
|
root,
|
|
domain::remote::RemoteRef::local(),
|
|
1_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn issue(number: u64) -> Issue {
|
|
Issue::new(
|
|
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
|
IssueNumber::new(number).unwrap(),
|
|
"Clarify onboarding",
|
|
MarkdownDoc::new("Initial description body"),
|
|
IssueStatus::Open,
|
|
IssuePriority::High,
|
|
MarkdownDoc::new("Carnet notes"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
IssueActor::User,
|
|
1_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn store(app_data_dir: String) -> FsAssistantContextStore {
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
FsAssistantContextStore::new(fs, app_data_dir)
|
|
}
|
|
|
|
struct FakeRuntime;
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for FakeRuntime {
|
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
|
|
fn prepare_invocation(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
Ok(SpawnSpec {
|
|
command: "assistant-cli".to_owned(),
|
|
args: Vec::new(),
|
|
cwd: cwd.clone(),
|
|
env: Vec::new(),
|
|
context_plan: Some(ContextInjectionPlan::File {
|
|
// Deliberately looks like the real ticket carnet path. The preparer
|
|
// must still materialise it under the isolated assistant cwd, never
|
|
// under the project root.
|
|
target: ".ideai/tickets/7/carnet.md".to_owned(),
|
|
}),
|
|
sandbox: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn mcp_profile() -> AgentProfile {
|
|
AgentProfile::new(
|
|
ProfileId::from_uuid(Uuid::from_u128(9)),
|
|
"Claude",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::stdin(),
|
|
None,
|
|
"{projectRoot}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
.with_mcp(McpCapability::new(
|
|
McpConfigStrategy::config_file(".mcp.json").unwrap(),
|
|
McpTransport::Stdio,
|
|
))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_context_is_embedded_and_injects_the_ticket() {
|
|
let tmp = TempDir::new();
|
|
let project = project(tmp.project_root());
|
|
let store = store(tmp.app_data_dir());
|
|
|
|
let ctx = store
|
|
.prepare_ticket_assistant_context(&project, &issue(7))
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = ctx.content.as_str();
|
|
assert!(body.contains("# Ticket Assistant"));
|
|
assert!(body.contains("- Ref: #7"));
|
|
assert!(body.contains("- Title: Clarify onboarding"));
|
|
assert!(body.contains("Initial description body"));
|
|
assert!(body.contains("Carnet notes"));
|
|
assert_eq!(ctx.project_root, project.root.as_str());
|
|
assert_eq!(ctx.relative_path, "ticket-assistant.md");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_data_override_replaces_the_embedded_default() {
|
|
let tmp = TempDir::new();
|
|
let app_data_dir = tmp.app_data_dir();
|
|
let override_path = PathBuf::from(&app_data_dir)
|
|
.join("assistant")
|
|
.join("ticket-assistant.md");
|
|
std::fs::create_dir_all(override_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&override_path, "# Custom assistant").unwrap();
|
|
|
|
let project = project(tmp.project_root());
|
|
let store = store(app_data_dir);
|
|
let ctx = store
|
|
.prepare_ticket_assistant_context(&project, &issue(8))
|
|
.await
|
|
.unwrap();
|
|
|
|
let body = ctx.content.as_str();
|
|
assert!(body.starts_with("# Custom assistant"));
|
|
assert!(!body.contains("You are IdeA's ticket editing assistant"));
|
|
assert!(body.contains("- Ref: #8"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_data() {
|
|
let tmp = TempDir::new();
|
|
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
|
let app_data_dir = tmp.app_data_dir();
|
|
let project = project(tmp.project_root());
|
|
let project_carnet = PathBuf::from(project.root.as_str())
|
|
.join(".ideai")
|
|
.join("tickets")
|
|
.join("7")
|
|
.join("carnet.md");
|
|
let prepared = PreparedContext {
|
|
content: MarkdownDoc::new("assistant-only context"),
|
|
relative_path: "ticket-assistant.md".to_owned(),
|
|
project_root: project.root.as_str().to_owned(),
|
|
};
|
|
let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned();
|
|
let preparer = TicketAssistantEnvironmentPreparer::new(
|
|
fs.clone(),
|
|
app_data_dir.clone(),
|
|
Arc::new(FakeRuntime),
|
|
Arc::new(move |_, _| {
|
|
Some(McpRuntime {
|
|
exe: "/opt/idea/idea".to_owned(),
|
|
endpoint: "127.0.0.1:4567".to_owned(),
|
|
project_id: "00000000000000000000000000000001".to_owned(),
|
|
requester: requester.clone(),
|
|
})
|
|
}),
|
|
Arc::new(FsSecretStore::new(fs.clone(), app_data_dir.clone())),
|
|
);
|
|
|
|
let env = preparer
|
|
.prepare_ticket_assistant(
|
|
&project,
|
|
issue(7).reference(),
|
|
&mcp_profile(),
|
|
&prepared,
|
|
"ticket-assistant:00000000000000000000000000000001:7",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let run_dir = PathBuf::from(&app_data_dir)
|
|
.join("assistant")
|
|
.join("tickets")
|
|
.join("00000000000000000000000000000001")
|
|
.join("7");
|
|
assert_eq!(env.cwd.as_str(), run_dir.to_string_lossy());
|
|
|
|
let isolated_carnet = run_dir
|
|
.join(".ideai")
|
|
.join("tickets")
|
|
.join("7")
|
|
.join("carnet.md");
|
|
let isolated_mcp = run_dir.join(".mcp.json");
|
|
assert_eq!(
|
|
fs.read(&RemotePath::new(
|
|
isolated_carnet.to_string_lossy().into_owned()
|
|
))
|
|
.await
|
|
.unwrap(),
|
|
b"assistant-only context"
|
|
);
|
|
let mcp = String::from_utf8(
|
|
fs.read(&RemotePath::new(
|
|
isolated_mcp.to_string_lossy().into_owned(),
|
|
))
|
|
.await
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(mcp.contains(r#""idea""#));
|
|
assert!(mcp.contains(r#""--requester""#));
|
|
assert!(mcp.contains("ticket-assistant:00000000000000000000000000000001:7"));
|
|
assert!(
|
|
!project_carnet.exists(),
|
|
"assistant context must not be written into the real project ticket carnet"
|
|
);
|
|
}
|