use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol_schema::v1::{
AuthMethod, ContentBlock, InitializeRequest, InitializeResponse, LoadSessionRequest,
LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, SessionConfigOption,
SessionId, TextContent,
};
use aion_integrations::contract::AgentHarness;
use aion_integrations::jsonrpc::{
IncomingMessage, JsonRpcConnection, JsonRpcId, JsonRpcNotification, JsonRpcRequest,
};
use aion_integrations::{AgentRunSpec, ChildEnvironment, HarnessError, HarnessWorkspace, Payload};
use async_trait::async_trait;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use tokio::process::{ChildStdin, ChildStdout, Command};
use crate::containment::ProcessGroup;
use crate::mcp::{McpServerSpec, require_transport_support};
use crate::permission::PermissionPolicy;
use crate::protocol;
use crate::session::{AcpSession, SessionStart, refuse_peer_request};
use crate::translate::{self, EventIdentity};
#[must_use]
pub fn registration_capabilities() -> aion_core::InterventionCapabilities {
translate::negotiated_capabilities()
}
#[derive(Clone, Debug)]
pub struct AcpHarness {
command: PathBuf,
args: Vec<OsString>,
declared_environment: Option<ChildEnvironment>,
env: Vec<(String, String)>,
env_removed: Vec<String>,
workspace: HarnessWorkspace,
additional_directories: Vec<PathBuf>,
permission_policy: PermissionPolicy,
exit_grace: Duration,
session_parameter: Option<String>,
mcp_servers: Vec<McpServerSpec>,
contained: bool,
}
fn config_options_wire(options: Option<Vec<SessionConfigOption>>) -> Option<serde_json::Value> {
options.map(|options| serde_json::json!({ "configOptions": options }))
}
pub(crate) struct OpenedTurn {
pub(crate) session_id: SessionId,
pub(crate) prompt_id: JsonRpcId,
pub(crate) deferred: Vec<JsonRpcNotification>,
pub(crate) load_replay: std::ops::Range<usize>,
pub(crate) load_session: bool,
pub(crate) config_options: Option<serde_json::Value>,
}
impl AcpHarness {
#[must_use]
pub fn new(
command: impl Into<PathBuf>,
args: impl IntoIterator<Item = impl Into<OsString>>,
workspace: HarnessWorkspace,
permission_policy: PermissionPolicy,
exit_grace: Duration,
) -> Self {
Self {
command: command.into(),
args: args.into_iter().map(Into::into).collect(),
declared_environment: None,
env: Vec::new(),
env_removed: Vec::new(),
workspace,
additional_directories: Vec::new(),
permission_policy,
exit_grace,
session_parameter: None,
mcp_servers: Vec::new(),
contained: false,
}
}
#[must_use]
pub const fn with_process_group_containment(mut self) -> Self {
self.contained = true;
self
}
#[must_use]
pub fn with_mcp_servers(mut self, servers: impl IntoIterator<Item = McpServerSpec>) -> Self {
self.mcp_servers = servers.into_iter().collect();
self
}
#[must_use]
pub fn mcp_servers(&self) -> &[McpServerSpec] {
&self.mcp_servers
}
fn mcp_servers_wire(&self) -> Vec<agent_client_protocol_schema::v1::McpServer> {
self.mcp_servers
.iter()
.map(McpServerSpec::to_wire)
.collect()
}
#[must_use]
pub fn with_session_parameter(mut self, parameter: impl Into<String>) -> Self {
self.session_parameter = Some(parameter.into());
self
}
fn session_for_attempt(&self, input: &Payload) -> Result<Option<SessionId>, HarnessError> {
let Some(parameter) = &self.session_parameter else {
return Ok(None);
};
let Ok(serde_json::Value::Object(fields)) =
serde_json::from_slice::<serde_json::Value>(input.bytes())
else {
return Ok(None);
};
let Some(value) = fields.get(parameter.as_str()) else {
return Ok(None);
};
let Some(id) = value.as_str() else {
return Err(HarnessError::configuration(format!(
"this worker takes the session to resume from the `{parameter}` parameter of the \
job, and this job carries it as something other than a string — a job that names \
a session must name it in text, and starting a fresh session instead would \
silently discard the conversation the job asked for"
)));
};
if id.trim().is_empty() {
return Err(HarnessError::configuration(format!(
"this worker takes the session to resume from the `{parameter}` parameter of the \
job, and this job carries it empty — an empty id names no session, and starting \
a fresh session instead would silently discard the conversation the job asked for"
)));
}
Ok(Some(SessionId::from(id.to_owned())))
}
#[must_use]
pub fn with_declared_environment(mut self, environment: ChildEnvironment) -> Self {
self.declared_environment = Some(environment);
self
}
#[must_use]
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push((key.into(), value.into()));
self
}
#[must_use]
pub fn without_env(mut self, key: impl Into<String>) -> Self {
self.env_removed.push(key.into());
self
}
#[must_use]
pub fn with_additional_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.additional_directories.push(directory.into());
self
}
fn command(&self, cwd: &Path) -> Command {
let mut command = Command::new(&self.command);
command.args(&self.args).current_dir(cwd);
if self.contained {
command.process_group(0);
}
if let Some(declared) = &self.declared_environment {
command.env_clear();
command.envs(declared.pairs().iter().map(|(key, value)| (key, value)));
}
command
.envs(self.env.iter().map(|(key, value)| (key, value)))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
for key in &self.env_removed {
command.env_remove(key);
}
command
}
fn spawn(&self, cwd: &Path) -> Result<tokio::process::Child, HarnessError> {
self.command(cwd).spawn().map_err(|source| {
HarnessError::transport(format!(
"failed to spawn the ACP agent `{}`: {source} — the command and its arguments are \
harness configuration (ACP has no standard launch flag) and must name a real \
executable",
self.command.display()
))
})
}
fn validate_paths(&self, cwd: &Path) -> Result<(), HarnessError> {
require_absolute("cwd", cwd)?;
for directory in &self.additional_directories {
require_absolute("additionalDirectories entry", directory)?;
}
for server in &self.mcp_servers {
if let Some(command) = server.absolute_path() {
require_absolute(&format!("MCP server `{}` command", server.name()), command)?;
}
}
Ok(())
}
}
fn require_absolute(label: &str, path: &Path) -> Result<(), HarnessError> {
if path.is_absolute() {
return Ok(());
}
Err(HarnessError::configuration(format!(
"the ACP {label} must be an absolute path, got `{}` — the protocol requires absolute \
session paths and this adapter will not resolve one against an unstated base",
path.display()
)))
}
fn take_child_io(
child: &mut tokio::process::Child,
) -> Result<(ChildStdout, ChildStdin), HarnessError> {
let stdout = child
.stdout
.take()
.ok_or_else(|| HarnessError::transport("the spawned ACP agent has no piped stdout"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| HarnessError::transport("the spawned ACP agent has no piped stdin"))?;
Ok((stdout, stdin))
}
async fn call<Req, Res, R, W>(
connection: &JsonRpcConnection<R, W>,
method: &str,
params: &Req,
deferred: &mut Vec<JsonRpcNotification>,
) -> Result<Res, HarnessError>
where
Req: Serialize,
Res: DeserializeOwned,
R: tokio::io::AsyncRead + Unpin + Send,
W: tokio::io::AsyncWrite + Unpin + Send,
{
let id = connection.next_request_id();
let encoded = serde_json::to_value(params).map_err(|source| {
HarnessError::protocol(format!("`{method}` params are not encodable: {source}"))
})?;
let request = JsonRpcRequest::new(id.clone(), method, Some(encoded));
connection.send_request(&request).await?;
let result = read_result_for(connection, method, &id, deferred).await?;
serde_json::from_value(result.clone()).map_err(|source| {
HarnessError::protocol(format!(
"the `{method}` result is not the shape ACP specifies ({source}): {result}"
))
})
}
async fn read_result_for<R, W>(
connection: &JsonRpcConnection<R, W>,
method: &str,
expected_id: &JsonRpcId,
deferred: &mut Vec<JsonRpcNotification>,
) -> Result<Value, HarnessError>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: tokio::io::AsyncWrite + Unpin + Send,
{
loop {
let message = connection.recv().await?.ok_or_else(|| {
HarnessError::transport(format!(
"the ACP agent closed the channel while `{method}` was outstanding — it exited \
before answering (its own diagnostics are on this process's stderr)"
))
})?;
match message {
IncomingMessage::Response(response) if &response.id == expected_id => {
if let Some(error) = response.error {
return Err(agent_error(method, &error));
}
return response.result.ok_or_else(|| {
HarnessError::protocol(format!(
"the `{method}` response carried neither result nor error"
))
});
}
IncomingMessage::Response(_) => {
return Err(HarnessError::protocol(format!(
"the ACP agent answered an id this client never requested while `{method}` was \
outstanding"
)));
}
IncomingMessage::Notification(note) => {
tracing::warn!(
method = %note.method,
during = %method,
"acp adapter: unexpected notification during the handshake; deferred to the \
transcript"
);
deferred.push(note);
}
IncomingMessage::Request(request) => {
refuse_peer_request(connection, request).await?;
}
}
}
}
fn agent_error(method: &str, error: &aion_integrations::jsonrpc::JsonRpcError) -> HarnessError {
if error.code == protocol::auth_required_code() {
return HarnessError::harness(format!(
"the ACP agent requires authentication before `{method}` (code {}): {} — an unattended \
activity cannot complete an interactive `authenticate` flow; supply the agent's \
credentials through the harness environment",
error.code, error.message
));
}
HarnessError::harness(format!(
"`{method}` failed (code {}): {}",
error.code, error.message
))
}
async fn initialize<R, W>(
connection: &JsonRpcConnection<R, W>,
deferred: &mut Vec<JsonRpcNotification>,
) -> Result<InitializeResponse, HarnessError>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: tokio::io::AsyncWrite + Unpin + Send,
{
let request = InitializeRequest::new(protocol::PROTOCOL_VERSION)
.client_capabilities(protocol::client_capabilities())
.client_info(protocol::client_info());
let response: InitializeResponse =
call(connection, protocol::METHOD_INITIALIZE, &request, deferred).await?;
translate::gate_protocol_version(response.protocol_version)?;
tracing::debug!(
version = response.protocol_version.as_u16(),
agent = ?response.agent_info,
capabilities = ?response.agent_capabilities,
auth_methods = ?auth_method_ids(&response.auth_methods),
"acp adapter: initialize negotiated"
);
Ok(response)
}
fn auth_method_ids(methods: &[AuthMethod]) -> Vec<String> {
methods
.iter()
.map(|method| method.id().to_string())
.collect()
}
impl AcpHarness {
pub(crate) async fn connect<R, W>(
&self,
connection: &JsonRpcConnection<R, W>,
cwd: PathBuf,
prompt: String,
prior: Option<SessionId>,
) -> Result<OpenedTurn, HarnessError>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: tokio::io::AsyncWrite + Unpin + Send,
{
let mut deferred = Vec::new();
let negotiated = initialize(connection, &mut deferred).await?;
require_transport_support(
&self.mcp_servers,
negotiated.agent_capabilities.mcp_capabilities.http,
)?;
self.open_turn(
connection,
cwd,
prompt,
deferred,
negotiated.agent_capabilities.load_session,
prior,
)
.await
}
async fn open_turn<R, W>(
&self,
connection: &JsonRpcConnection<R, W>,
cwd: PathBuf,
prompt: String,
mut deferred: Vec<JsonRpcNotification>,
load_session: bool,
prior: Option<SessionId>,
) -> Result<OpenedTurn, HarnessError>
where
R: tokio::io::AsyncRead + Unpin + Send,
W: tokio::io::AsyncWrite + Unpin + Send,
{
let (session_id, load_replay, advertised_config) = match prior {
Some(prior_id) if load_session => {
let load_request = LoadSessionRequest::new(prior_id.clone(), cwd)
.mcp_servers(self.mcp_servers_wire())
.additional_directories(self.additional_directories.clone());
let replay_start = deferred.len();
let loaded: LoadSessionResponse = call(
connection,
protocol::METHOD_SESSION_LOAD,
&load_request,
&mut deferred,
)
.await?;
let load_replay = replay_start..deferred.len();
tracing::debug!(
session = %prior_id,
replayed = load_replay.len(),
"acp adapter: session loaded"
);
(
prior_id,
load_replay,
config_options_wire(loaded.config_options),
)
}
Some(prior_id) => {
return Err(HarnessError::configuration(format!(
"this job asks to resume ACP session `{prior_id}`, and the configured agent \
did not advertise the `loadSession` capability in its initialize response — \
it cannot load a prior session, and starting a fresh one instead would \
silently discard the conversation the job asked for; run this job against \
an agent that supports session loading, or dispatch it without a session id"
)));
}
None => {
let new_session = NewSessionRequest::new(cwd)
.mcp_servers(self.mcp_servers_wire())
.additional_directories(self.additional_directories.clone());
let session: NewSessionResponse = call(
connection,
protocol::METHOD_SESSION_NEW,
&new_session,
&mut deferred,
)
.await?;
tracing::debug!(
session = %session.session_id,
modes = ?session.modes,
"acp adapter: session created"
);
let advertised = config_options_wire(session.config_options);
(session.session_id, 0..0, advertised)
}
};
let prompt_request = PromptRequest::new(
session_id.clone(),
vec![ContentBlock::Text(TextContent::new(prompt))],
);
let params = serde_json::to_value(&prompt_request).map_err(|source| {
HarnessError::protocol(format!("session/prompt params are not encodable: {source}"))
})?;
let prompt_id = connection.next_request_id();
let frame = JsonRpcRequest::new(
prompt_id.clone(),
protocol::METHOD_SESSION_PROMPT,
Some(params),
);
connection.send_request(&frame).await?;
Ok(OpenedTurn {
session_id,
prompt_id,
deferred,
load_replay,
load_session,
config_options: advertised_config,
})
}
}
#[async_trait]
impl AgentHarness for AcpHarness {
type Session = AcpSession<ChildStdout, ChildStdin>;
async fn start(&self, spec: AgentRunSpec) -> Result<Self::Session, HarnessError> {
let cwd = self.workspace.for_attempt(&spec.input)?;
self.validate_paths(&cwd)?;
if let Some(declared) = &self.declared_environment {
declared.require_for_program(&self.command)?;
}
let reserved: Vec<&str> = self.session_parameter.iter().map(String::as_str).collect();
let prompt = self.workspace.prompt_for_attempt(&spec.input, &reserved)?;
let prior = self.session_for_attempt(&spec.input)?;
let mut child = self.spawn(&cwd)?;
let process_group = if self.contained {
let group = child.id().and_then(ProcessGroup::of_leader);
if group.is_none() {
tracing::warn!(
"acp adapter: the spawned agent reported no usable process id, so its \
descendants cannot be stopped as a tree; only the direct child will be \
signalled"
);
}
group
} else {
None
};
let (stdout, stdin) = take_child_io(&mut child)?;
let connection = Arc::new(JsonRpcConnection::new(stdout, stdin));
let opened = match self.connect(connection.as_ref(), cwd, prompt, prior).await {
Ok(opened) => opened,
Err(error) => {
if let Some(group) = process_group {
group.kill();
}
return Err(error);
}
};
let identity = EventIdentity {
workflow_id: spec.workflow_id,
run_id: spec.run_id,
activity_id: spec.activity_id,
attempt: spec.attempt,
};
Ok(AcpSession::start(SessionStart {
connection,
session_id: opened.session_id,
prompt_id: opened.prompt_id,
policy: self.permission_policy,
identity,
deferred: opened.deferred,
load_replay: opened.load_replay,
child: Some(child),
exit_grace: self.exit_grace,
load_session: opened.load_session,
config_options: opened.config_options,
process_group,
}))
}
}
#[cfg(test)]
mod tests {
use std::error::Error;
use std::path::{Path, PathBuf};
use std::time::Duration;
use aion_core::{ActivityId, Payload, RunId, WorkflowId};
use aion_integrations::{HarnessError, HarnessWorkspace};
use super::{AcpHarness, AgentRunSpec, registration_capabilities};
use crate::permission::PermissionPolicy;
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
#[test]
fn the_published_set_is_exactly_what_the_adapter_can_translate() {
use agent_client_protocol_schema::v1::SessionId;
use aion_core::{ApprovalDecision, InjectPriority, InterventionKind};
use crate::translate::intervention_to_notification;
let every_primitive = [
InterventionKind::InjectMessage {
text: "steer".to_owned(),
priority: InjectPriority::Interrupt,
},
InterventionKind::Cancel {
reason: "stop".to_owned(),
},
InterventionKind::PauseResume { paused: true },
InterventionKind::UpdateBudget {
max_tokens: Some(1),
max_turns: None,
},
InterventionKind::RespondToApproval {
call_id: "call-1".to_owned(),
decision: ApprovalDecision::Approve,
note: None,
},
];
let session = SessionId::new("sess-1");
let published = registration_capabilities();
for kind in &every_primitive {
let primitive = kind.primitive();
assert_eq!(
published.supports_primitive(primitive),
intervention_to_notification(&session, kind).is_ok(),
"the published registration set disagrees with the translation for {primitive:?}"
);
}
}
#[test]
fn the_registration_set_is_the_negotiated_set() {
assert_eq!(
registration_capabilities(),
crate::translate::negotiated_capabilities()
);
}
const GRACE: Duration = Duration::from_secs(2);
fn spec_with_input(input: Payload) -> AgentRunSpec {
AgentRunSpec::new(
WorkflowId::new_v4(),
RunId::new_v4(),
ActivityId::from_sequence_position(1),
1,
"agent",
input,
)
}
const WORKSPACE: &str = "/srv/workspace";
fn fixed(directory: &str) -> HarnessWorkspace {
HarnessWorkspace::Fixed(PathBuf::from(directory))
}
fn harness() -> AcpHarness {
AcpHarness::new(
"/usr/local/bin/gemini",
["--acp"],
fixed(WORKSPACE),
PermissionPolicy::Deny,
GRACE,
)
}
fn argv(harness: &AcpHarness) -> TestResult<Vec<String>> {
let decoded = harness
.command(Path::new(WORKSPACE))
.as_std()
.get_args()
.map(|arg| arg.to_str().map(str::to_owned).ok_or("argv is not UTF-8"))
.collect::<Result<Vec<_>, _>>()?;
Ok(decoded)
}
#[test]
fn the_configured_command_and_args_are_passed_through_verbatim() -> TestResult {
assert_eq!(argv(&harness())?, vec!["--acp".to_owned()]);
let no_args = AcpHarness::new(
"opencode",
Vec::<String>::new(),
fixed(WORKSPACE),
PermissionPolicy::AllowOnce,
GRACE,
);
assert!(argv(&no_args)?.is_empty(), "no args configured, none sent");
let two_args = AcpHarness::new(
"npx",
["@agentclientprotocol/claude-agent-acp", "--verbose"],
fixed(WORKSPACE),
PermissionPolicy::AllowOnce,
GRACE,
);
assert_eq!(
argv(&two_args)?,
vec![
"@agentclientprotocol/claude-agent-acp".to_owned(),
"--verbose".to_owned()
]
);
Ok(())
}
#[test]
fn the_child_runs_in_the_session_cwd() -> TestResult {
let command = harness().command(Path::new(WORKSPACE));
let directory = command
.as_std()
.get_current_dir()
.ok_or("the child's working directory is set to the session cwd")?;
assert_eq!(directory, Path::new(WORKSPACE));
Ok(())
}
#[test]
fn each_job_roots_its_session_in_the_tree_that_job_named() -> TestResult {
let harness = AcpHarness::new(
"/usr/local/bin/gemini",
["--acp"],
HarnessWorkspace::PerRun("repo".to_owned()),
PermissionPolicy::Deny,
GRACE,
);
let mut seen = Vec::new();
for tree in ["/srv/lane-3", "/srv/lane-9"] {
let spec = spec_with_input(Payload::from_json(&serde_json::json!({ "repo": tree }))?);
let cwd = harness.workspace.for_attempt(&spec.input)?;
let command = harness.command(&cwd);
let directory = command
.as_std()
.get_current_dir()
.ok_or("the child's working directory is set from the job")?
.to_path_buf();
seen.push(directory);
}
assert_eq!(
seen,
vec![PathBuf::from("/srv/lane-3"), PathBuf::from("/srv/lane-9")]
);
Ok(())
}
#[test]
fn a_job_that_names_no_tree_is_refused_before_any_spawn() -> TestResult {
let harness = AcpHarness::new(
"/usr/local/bin/gemini",
["--acp"],
HarnessWorkspace::PerRun("repo".to_owned()),
PermissionPolicy::Deny,
GRACE,
);
let spec = spec_with_input(Payload::from_json(
&serde_json::json!({ "branch": "main" }),
)?);
let error = harness
.workspace
.for_attempt(&spec.input)
.err()
.ok_or("a job with no directory cannot launch an agent")?;
assert!(
error.is_deterministic(),
"the same input meets the same wall on every attempt: {error:?}"
);
Ok(())
}
#[test]
fn removed_env_vars_are_expressed_as_child_removals() {
let harness = harness()
.with_env("ANTHROPIC_API_KEY", "stray-ambient-key")
.without_env("ANTHROPIC_API_KEY");
let command = harness.command(Path::new(WORKSPACE));
let removal_expressed = command
.as_std()
.get_envs()
.any(|(key, value)| key == "ANTHROPIC_API_KEY" && value.is_none());
assert!(
removal_expressed,
"without_env must express a child-side env removal on the spawned command"
);
}
#[test]
fn a_relative_cwd_is_refused_as_a_terminal_configuration_error() -> TestResult {
let harness = AcpHarness::new(
"agent",
["acp"],
fixed("workspace"),
PermissionPolicy::Deny,
GRACE,
);
let error = harness
.validate_paths(Path::new("workspace"))
.err()
.ok_or("a relative cwd must be refused")?;
assert!(
matches!(error, HarnessError::Configuration { .. }),
"a document-sourced path refusal is a configuration refusal, not a protocol fault: \
{error:?}"
);
assert!(
error.is_deterministic(),
"retrying a relative cwd re-reads the same document and hits the same wall, so the \
refusal must be terminal rather than spend the whole attempt budget: {error:?}"
);
let message = error.to_string();
assert!(
message.contains("cwd") && message.contains("absolute"),
"the refusal names the setting and the requirement: {message}"
);
Ok(())
}
#[test]
fn a_relative_additional_directory_is_refused() -> TestResult {
let harness = AcpHarness::new(
"agent",
["acp"],
fixed("/srv"),
PermissionPolicy::Deny,
GRACE,
)
.with_additional_directory("shared");
let error = harness
.validate_paths(Path::new("/srv"))
.err()
.ok_or("a relative additional directory must be refused")?;
assert!(
error.to_string().contains("additionalDirectories"),
"the refusal names the setting: {error}"
);
assert!(
error.is_deterministic(),
"the second document-sourced path travels the same classification as `cwd`; without \
this arm the pin above could hold while this one silently stayed retryable: {error:?}"
);
Ok(())
}
#[test]
fn absolute_paths_pass_validation() -> Result<(), HarnessError> {
harness()
.with_additional_directory("/srv/shared")
.validate_paths(Path::new(WORKSPACE))
}
#[test]
fn a_relative_directory_from_the_job_is_refused_the_same_way() -> TestResult {
let harness = AcpHarness::new(
"agent",
["acp"],
HarnessWorkspace::PerRun("repo".to_owned()),
PermissionPolicy::Deny,
GRACE,
);
let spec = spec_with_input(Payload::from_json(
&serde_json::json!({ "repo": "lane-3" }),
)?);
let cwd = harness.workspace.for_attempt(&spec.input)?;
let error = harness
.validate_paths(&cwd)
.err()
.ok_or("a relative directory is relative wherever it came from")?;
assert!(
error.is_deterministic(),
"a job-supplied relative path is identically relative on every attempt: {error:?}"
);
Ok(())
}
#[test]
fn the_prompt_is_read_through_the_same_workspace_that_places_the_agent() -> TestResult {
let harness = AcpHarness::new(
"agent",
["acp"],
HarnessWorkspace::PerRun("repo".to_owned()),
PermissionPolicy::Deny,
GRACE,
);
let spec = spec_with_input(Payload::from_json(&serde_json::json!({
"repo": WORKSPACE,
"prompt": "review the diff",
}))?);
assert_eq!(
harness.workspace.for_attempt(&spec.input)?,
PathBuf::from(WORKSPACE)
);
assert_eq!(
harness.workspace.prompt_for_attempt(&spec.input, &[])?,
"review the diff"
);
Ok(())
}
}