Files
IdeaSDK/crates/infrastructure/tests/assistant_context_store.rs

378 lines
12 KiB
Rust

use std::path::PathBuf;
use std::sync::Arc;
use application::McpRuntime;
use async_trait::async_trait;
use domain::permission::{EffectivePermissions, PermissionSet, Posture};
use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer};
use domain::profile::{
McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, StructuredAdapter,
};
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,
))
}
fn opencode_profile() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(10)),
"OpenCode",
"opencode",
Vec::new(),
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder", None, None).unwrap(),
)
.with_mcp(McpCapability::new(
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
McpTransport::Stdio,
))
}
fn effective(fallback: Posture) -> EffectivePermissions {
domain::permission::resolve(Some(&PermissionSet::new(Vec::new(), fallback)), None).unwrap()
}
#[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",
None,
)
.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"
);
}
#[tokio::test]
async fn opencode_ticket_assistant_projects_project_permissions_into_isolated_config() {
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 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,
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(), tmp.app_data_dir())),
);
let env = preparer
.prepare_ticket_assistant(
&project,
issue(7).reference(),
&opencode_profile(),
&prepared,
"ticket-assistant:00000000000000000000000000000001:7",
Some(&effective(Posture::Deny)),
)
.await
.unwrap();
let config_path = PathBuf::from(env.cwd.as_str()).join("opencode.json");
let config: serde_json::Value = serde_json::from_slice(
&fs.read(&RemotePath::new(config_path.to_string_lossy().into_owned()))
.await
.unwrap(),
)
.unwrap();
assert_eq!(config["permission"]["bash"], "deny");
assert_eq!(config["permission"]["edit"], "deny");
}
#[tokio::test]
async fn claude_ticket_assistant_does_not_project_effective_permissions() {
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 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,
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(), tmp.app_data_dir())),
);
let env = preparer
.prepare_ticket_assistant(
&project,
issue(7).reference(),
&mcp_profile(),
&prepared,
"ticket-assistant:00000000000000000000000000000001:7",
Some(&effective(Posture::Deny)),
)
.await
.unwrap();
let mcp_path = PathBuf::from(env.cwd.as_str()).join(".mcp.json");
let mcp: serde_json::Value = serde_json::from_slice(
&fs.read(&RemotePath::new(mcp_path.to_string_lossy().into_owned()))
.await
.unwrap(),
)
.unwrap();
assert!(mcp.get("permission").is_none());
}