test(acp): add terminal delegation fixtures and fix shell singleton (#7923)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-03-16 22:02:08 +08:00
committed by GitHub
parent 5df2995695
commit 94eaacc06f
12 changed files with 727 additions and 142 deletions
+277 -99
View File
@@ -5,17 +5,19 @@
#[path = "../fixtures/mod.rs"]
pub mod fixtures;
use fixtures::{
Connection, FsFixture, OpenAiFixture, PermissionDecision, Session, SessionResult,
TestConnectionConfig,
assert_notifications, Connection, FsFixture, Notification, OpenAiFixture, PermissionDecision,
Session, SessionResult, TerminalCall, TerminalFixture, TestConnectionConfig,
};
use fs_err as fs;
use goose::config::base::CONFIG_YAML_NAME;
use goose::config::GooseMode;
use goose::providers::provider_registry::ProviderConstructor;
use goose_test_support::{McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL};
use sacp::schema::{McpServer, McpServerHttp, ModelId, SessionModeId, ToolCallStatus};
use sacp::schema::{McpServer, McpServerHttp, ModelId, SessionModeId, ToolCallStatus, ToolKind};
use std::sync::Arc;
const SHELL_TEST_CONTENT: &str = "test-shell-content-98765";
pub async fn run_config_mcp<C: Connection>() {
let temp_dir = tempfile::tempdir().unwrap();
let expected_session_id = C::expected_session_id();
@@ -54,6 +56,15 @@ pub async fn run_config_mcp<C: Connection>() {
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -94,6 +105,15 @@ pub async fn run_fs_read_text_file_true<C: Connection>() {
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.text, "test-read-content-12345");
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallKind(ToolKind::Read),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
fs.assert_called();
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -133,6 +153,15 @@ pub async fn run_fs_write_text_file_false<C: Connection>() {
fs::read_to_string("/tmp/test_acp_write.txt").unwrap(),
"test-write-content-67890"
);
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -169,6 +198,16 @@ pub async fn run_fs_write_text_file_true<C: Connection>() {
let output = session.prompt(prompt, PermissionDecision::AllowOnce).await;
assert!(!output.text.is_empty());
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallKind(ToolKind::Edit),
Notification::ToolCallContent("diff".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
fs.assert_called();
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -248,6 +287,15 @@ pub async fn run_load_mode<C: Connection>() {
expected_session_id.set(&loaded.session_id().0);
let output = loaded.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.tool_status.unwrap(), ToolCallStatus::Failed);
assert_notifications(
&loaded.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Failed),
Notification::AgentMessage,
],
);
}
pub async fn run_load_model<C: Connection>() {
@@ -277,6 +325,64 @@ pub async fn run_load_model<C: Connection>() {
assert_eq!(&*models.unwrap().current_model_id.0, "o4-mini");
}
pub async fn run_load_session_mcp<C: Connection>() {
let expected_session_id = C::expected_session_id();
let prompt = "Use the get_code tool and output only its result.";
let mcp = McpFixture::new(expected_session_id.clone()).await;
let mcp_url = mcp.url.clone();
// Two rounds of tool call + tool result: one for new session, one for loaded session.
let openai = OpenAiFixture::new(
vec![
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result.txt"),
),
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result.txt"),
),
],
expected_session_id.clone(),
)
.await;
let mcp_servers = vec![McpServer::Http(McpServerHttp::new("mcp-fixture", &mcp_url))];
let config = TestConnectionConfig {
mcp_servers: mcp_servers.clone(),
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
expected_session_id.set(&session.session_id().0);
// First prompt: tool should work in the new session.
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.text, FAKE_CODE, "tool call failed in new session");
// Load the same session with MCP servers re-specified.
let session_id = session.session_id().0.to_string();
let SessionResult {
session: mut loaded_session,
..
} = conn.load_session(&session_id, mcp_servers).await;
// Second prompt: tool should work in the loaded session.
let output = loaded_session
.prompt(prompt, PermissionDecision::Cancel)
.await;
assert_eq!(output.text, FAKE_CODE, "tool call failed in loaded session");
}
pub async fn run_mode_set<C: Connection>() {
let temp_dir = tempfile::tempdir().unwrap();
let expected_session_id = C::expected_session_id();
@@ -327,12 +433,72 @@ pub async fn run_mode_set<C: Connection>() {
expected_session_id.set(&session_b.session_id().0);
let output = session_b.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.tool_status.unwrap(), ToolCallStatus::Failed);
assert_notifications(
&session_b.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Failed),
Notification::AgentMessage,
],
);
// Auto mode ignores Cancel — tool succeeds without permission prompt
conn.reset_openai();
expected_session_id.set(&session_a.session_id().0);
let output = session_a.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session_a.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
}
pub async fn run_mode_set_error<C: Connection>(
mode_id: &str,
session_id_override: Option<&str>,
expected: sacp::Error,
) {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
let target_session_id = session_id_override
.map(str::to_string)
.unwrap_or_else(|| session.session_id().0.to_string());
let err = conn
.set_mode(&target_session_id, mode_id)
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(sacp_err, expected);
}
#[macro_export]
macro_rules! tests_mode_set_error {
($conn:ty) => {
#[test_case::test_case("not_a_mode", None, sacp::Error::invalid_params().data("Invalid mode: not_a_mode") ; "invalid mode")]
#[test_case::test_case("auto", Some("nonexistent-session-id"), sacp::Error::invalid_params().data("Session not found: nonexistent-session-id") ; "session not found")]
fn test_mode_set_error(
mode_id: &'static str,
session_id: Option<&'static str>,
expected: sacp::Error,
) {
common_tests::fixtures::run_test(async move {
common_tests::run_mode_set_error::<$conn>(
mode_id, session_id, expected,
)
.await
});
}
};
}
pub async fn run_model_list<C: Connection>() {
@@ -482,6 +648,7 @@ pub async fn run_prompt_basic<C: Connection>() {
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
assert_eq!(output.text, "2");
assert_notifications(&session.notifications(), &[Notification::AgentMessage]);
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -565,6 +732,15 @@ pub async fn run_prompt_image<C: Connection>() {
)
.await;
assert_eq!(output.text, "Hello Goose!\nThis is a test image.");
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -592,67 +768,10 @@ pub async fn run_prompt_image_attachment<C: Connection>() {
)
.await;
assert!(output.text.contains("Hello Goose!"));
assert_notifications(&session.notifications(), &[Notification::AgentMessage]);
expected_session_id.assert_matches(&session.session_id().0);
}
pub async fn run_load_session_mcp<C: Connection>() {
let expected_session_id = C::expected_session_id();
let prompt = "Use the get_code tool and output only its result.";
let mcp = McpFixture::new(expected_session_id.clone()).await;
let mcp_url = mcp.url.clone();
// Two rounds of tool call + tool result: one for new session, one for loaded session.
let openai = OpenAiFixture::new(
vec![
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result.txt"),
),
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result.txt"),
),
],
expected_session_id.clone(),
)
.await;
let mcp_servers = vec![McpServer::Http(McpServerHttp::new("mcp-fixture", &mcp_url))];
let config = TestConnectionConfig {
mcp_servers: mcp_servers.clone(),
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
expected_session_id.set(&session.session_id().0);
// First prompt: tool should work in the new session.
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
assert_eq!(output.text, FAKE_CODE, "tool call failed in new session");
// Load the same session with MCP servers re-specified.
let session_id = session.session_id().0.to_string();
let SessionResult {
session: mut loaded_session,
..
} = conn.load_session(&session_id, mcp_servers).await;
// Second prompt: tool should work in the loaded session.
let output = loaded_session
.prompt(prompt, PermissionDecision::Cancel)
.await;
assert_eq!(output.text, FAKE_CODE, "tool call failed in loaded session");
}
pub async fn run_prompt_mcp<C: Connection>() {
let expected_session_id = C::expected_session_id();
let mcp = McpFixture::new(expected_session_id.clone()).await;
@@ -686,6 +805,15 @@ pub async fn run_prompt_mcp<C: Connection>() {
)
.await;
assert_eq!(output.text, FAKE_CODE);
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
expected_session_id.assert_matches(&session.session_id().0);
}
@@ -726,44 +854,94 @@ pub async fn run_prompt_skill<C: Connection>() {
expected_session_id.assert_matches(&session.session_id().0);
}
pub async fn run_mode_set_error<C: Connection>(
mode_id: &str,
session_id_override: Option<&str>,
expected: sacp::Error,
) {
let openai = OpenAiFixture::new(vec![], C::expected_session_id()).await;
let mut conn = C::new(TestConnectionConfig::default(), openai).await;
let SessionResult { session, .. } = conn.new_session().await;
pub async fn run_shell_terminal_false<C: Connection>() {
let expected_session_id = C::expected_session_id();
let prompt = format!("Run the command echo {SHELL_TEST_CONTENT} and output only its result.");
let openai = OpenAiFixture::new(
vec![
(
prompt.clone(),
include_str!("../test_data/openai_shell_tool_call.txt"),
),
(
SHELL_TEST_CONTENT.into(),
include_str!("../test_data/openai_shell_tool_result.txt"),
),
],
expected_session_id.clone(),
)
.await;
let target_session_id = session_id_override
.map(str::to_string)
.unwrap_or_else(|| session.session_id().0.to_string());
let err = conn
.set_mode(&target_session_id, mode_id)
.await
.unwrap_err();
let sacp_err = err.downcast::<sacp::Error>().unwrap();
assert_eq!(sacp_err, expected);
}
#[macro_export]
macro_rules! tests_mode_set_error {
($conn:ty) => {
#[test_case::test_case("not_a_mode", None, sacp::Error::invalid_params().data("Invalid mode: not_a_mode") ; "invalid mode")]
#[test_case::test_case("auto", Some("nonexistent-session-id"), sacp::Error::invalid_params().data("Session not found: nonexistent-session-id") ; "session not found")]
fn test_mode_set_error(
mode_id: &'static str,
session_id: Option<&'static str>,
expected: sacp::Error,
) {
common_tests::fixtures::run_test(async move {
common_tests::run_mode_set_error::<$conn>(
mode_id, session_id, expected,
)
.await
});
}
let config = TestConnectionConfig {
builtins: vec!["developer".to_string()],
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
expected_session_id.set(&session.session_id().0);
let output = session.prompt(&prompt, PermissionDecision::AllowOnce).await;
assert!(!output.text.is_empty());
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallContent("content".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
expected_session_id.assert_matches(&session.session_id().0);
}
pub async fn run_shell_terminal_true<C: Connection>() {
let expected_session_id = C::expected_session_id();
let prompt = format!("Run the command echo {SHELL_TEST_CONTENT} and output only its result.");
let openai = OpenAiFixture::new(
vec![
(
prompt.clone(),
include_str!("../test_data/openai_shell_tool_call.txt"),
),
(
SHELL_TEST_CONTENT.into(),
include_str!("../test_data/openai_shell_tool_result.txt"),
),
],
expected_session_id.clone(),
)
.await;
let command = format!("echo {SHELL_TEST_CONTENT}");
let output_text = format!("{SHELL_TEST_CONTENT}\n");
let tid = String::from("term-1");
let terminal = TerminalFixture::new(vec![
TerminalCall::Create(command.clone(), tid.clone()),
TerminalCall::WaitForExit(tid.clone(), 0),
TerminalCall::Output(tid.clone(), output_text.clone(), 0),
TerminalCall::Release(tid),
]);
let config = TestConnectionConfig {
builtins: vec!["developer".to_string()],
terminal: Some(terminal.clone()),
..Default::default()
};
let mut conn = C::new(config, openai).await;
let SessionResult { mut session, .. } = conn.new_session().await;
expected_session_id.set(&session.session_id().0);
let output = session.prompt(&prompt, PermissionDecision::AllowOnce).await;
assert_eq!(output.tool_status, Some(ToolCallStatus::Completed));
assert_notifications(
&session.notifications(),
&[
Notification::ToolCall,
Notification::ToolCallKind(ToolKind::Execute),
Notification::ToolCallContent("terminal".into()),
Notification::ToolCallStatus(ToolCallStatus::Completed),
Notification::AgentMessage,
],
);
terminal.assert_called();
expected_session_id.assert_matches(&session.session_id().0);
}
+194 -2
View File
@@ -14,8 +14,11 @@ use goose::session_context::SESSION_ID_HEADER;
use goose_acp::server::{serve, GooseAcpAgent};
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
use sacp::schema::{
AuthMethod, McpServer, ReadTextFileRequest, ReadTextFileResponse, SessionModeState,
SessionModelState, ToolCallStatus, WriteTextFileRequest, WriteTextFileResponse,
AuthMethod, CreateTerminalResponse, KillTerminalCommandResponse, McpServer,
ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalResponse, SessionModeState,
SessionModelState, SessionUpdate, TerminalExitStatus, TerminalId, TerminalOutputResponse,
ToolCallContent, ToolCallStatus, ToolKind, WaitForTerminalExitResponse, WriteTextFileRequest,
WriteTextFileResponse,
};
use std::collections::VecDeque;
use std::future::Future;
@@ -200,6 +203,74 @@ pub struct TestOutput {
pub tool_status: Option<ToolCallStatus>,
}
#[derive(Debug, PartialEq)]
pub enum Notification {
UserMessage,
AgentMessage,
AgentThought,
ToolCall,
ToolCallKind(ToolKind),
ToolCallContent(String),
ToolCallStatus(ToolCallStatus),
Plan,
AvailableCommands,
CurrentMode,
ConfigOption,
}
pub fn to_notifications(updates: &[SessionUpdate]) -> Vec<Notification> {
let mut out = Vec::new();
for u in updates {
match u {
SessionUpdate::UserMessageChunk(_) => {
if out.last() != Some(&Notification::UserMessage) {
out.push(Notification::UserMessage);
}
}
SessionUpdate::AgentMessageChunk(_) => {
if out.last() != Some(&Notification::AgentMessage) {
out.push(Notification::AgentMessage);
}
}
SessionUpdate::AgentThoughtChunk(_) => {
if out.last() != Some(&Notification::AgentThought) {
out.push(Notification::AgentThought);
}
}
SessionUpdate::ToolCall(_) => out.push(Notification::ToolCall),
SessionUpdate::ToolCallUpdate(upd) => {
if let Some(kind) = upd.fields.kind {
out.push(Notification::ToolCallKind(kind));
}
if let Some(ref content) = upd.fields.content {
for c in content {
let tag = match c {
ToolCallContent::Content(_) => "content",
ToolCallContent::Diff(_) => "diff",
ToolCallContent::Terminal(_) => "terminal",
_ => "unknown",
};
out.push(Notification::ToolCallContent(tag.into()));
}
}
if let Some(status) = upd.fields.status {
out.push(Notification::ToolCallStatus(status));
}
}
SessionUpdate::Plan(_) => out.push(Notification::Plan),
SessionUpdate::AvailableCommandsUpdate(_) => out.push(Notification::AvailableCommands),
SessionUpdate::CurrentModeUpdate(_) => out.push(Notification::CurrentMode),
SessionUpdate::ConfigOptionUpdate(_) => out.push(Notification::ConfigOption),
_ => {}
}
}
out
}
pub fn assert_notifications(actual: &[Notification], expected: &[Notification]) {
assert_eq!(actual, expected);
}
type ReadTextFileHandler =
Arc<dyn Fn(&ReadTextFileRequest) -> Result<ReadTextFileResponse, String> + Send + Sync>;
type WriteTextFileHandler =
@@ -266,6 +337,124 @@ impl FsFixture {
}
}
/// Expected terminal calls. Each variant carries (expected_input, return_value) data,
/// like OpenAiFixture's (pattern, response) pairs.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TerminalCall {
Create(String, String), // (command, terminal_id)
WaitForExit(String, u32), // (terminal_id, exit_code)
Output(String, String, u32), // (terminal_id, text, exit_code)
Release(String), // terminal_id
Kill(String), // terminal_id
}
impl TerminalCall {
fn name(&self) -> &'static str {
match self {
Self::Create(..) => "create",
Self::WaitForExit(..) => "wait_for_exit",
Self::Output(..) => "output",
Self::Release(_) => "release",
Self::Kill(_) => "kill",
}
}
}
pub struct TerminalFixture {
queue: Arc<Mutex<VecDeque<TerminalCall>>>,
errors: Arc<Mutex<Vec<String>>>,
}
impl TerminalFixture {
pub fn new(calls: Vec<TerminalCall>) -> Arc<Self> {
Arc::new(Self {
queue: Arc::new(Mutex::new(VecDeque::from(calls))),
errors: Arc::new(Mutex::new(Vec::new())),
})
}
fn pop(&self, expected: &str) -> Option<TerminalCall> {
let Some(call) = self.queue.lock().unwrap().pop_front() else {
self.record_error(format!("unexpected {expected}: queue empty"));
return None;
};
if call.name() != expected {
self.record_error(format!("expected {expected}, got {}", call.name()));
return None;
}
Some(call)
}
fn record_error(&self, msg: String) {
self.errors.lock().unwrap().push(msg);
}
fn validate_terminal_id(&self, method: &str, expected: &str, actual: &TerminalId) {
if expected != actual.0.as_ref() {
self.record_error(format!(
"{method}: expected terminal_id {expected}, got {actual}"
));
}
}
pub fn on_create(&self, command: &str) -> CreateTerminalResponse {
if let Some(TerminalCall::Create(expect_command, terminal_id)) = self.pop("create") {
if command != expect_command {
self.record_error(format!(
"create: expected command {expect_command}, got {command}"
));
}
CreateTerminalResponse::new(TerminalId::new(terminal_id))
} else {
CreateTerminalResponse::new(TerminalId::new("error"))
}
}
pub fn on_wait_for_exit(&self, terminal_id: &TerminalId) -> WaitForTerminalExitResponse {
if let Some(TerminalCall::WaitForExit(expected_id, exit_code)) = self.pop("wait_for_exit") {
self.validate_terminal_id("wait_for_exit", &expected_id, terminal_id);
WaitForTerminalExitResponse::new(TerminalExitStatus::new().exit_code(exit_code))
} else {
WaitForTerminalExitResponse::new(TerminalExitStatus::new().exit_code(1))
}
}
pub fn on_output(&self, terminal_id: &TerminalId) -> TerminalOutputResponse {
if let Some(TerminalCall::Output(expected_id, text, exit_code)) = self.pop("output") {
self.validate_terminal_id("output", &expected_id, terminal_id);
TerminalOutputResponse::new(text, false)
.exit_status(TerminalExitStatus::new().exit_code(exit_code))
} else {
TerminalOutputResponse::new("", false)
}
}
pub fn on_release(&self, terminal_id: &TerminalId) -> ReleaseTerminalResponse {
if let Some(TerminalCall::Release(expected_id)) = self.pop("release") {
self.validate_terminal_id("release", &expected_id, terminal_id);
}
ReleaseTerminalResponse::new()
}
pub fn on_kill(&self, terminal_id: &TerminalId) -> KillTerminalCommandResponse {
if let Some(TerminalCall::Kill(expected_id)) = self.pop("kill") {
self.validate_terminal_id("kill", &expected_id, terminal_id);
}
KillTerminalCommandResponse::new()
}
pub fn assert_called(&self) {
let errors = self.errors.lock().unwrap();
assert!(errors.is_empty(), "terminal fixture errors: {errors:?}");
let queue = self.queue.lock().unwrap();
assert!(
queue.is_empty(),
"terminal fixture has unconsumed calls: {queue:?}"
);
}
}
pub struct SessionResult<S> {
pub session: S,
pub models: Option<SessionModelState>,
@@ -281,6 +470,7 @@ pub struct TestConnectionConfig {
pub provider_factory: Option<ProviderConstructor>,
pub read_text_file: Option<ReadTextFileHandler>,
pub write_text_file: Option<WriteTextFileHandler>,
pub terminal: Option<Arc<TerminalFixture>>,
}
impl Default for TestConnectionConfig {
@@ -294,6 +484,7 @@ impl Default for TestConnectionConfig {
provider_factory: None,
read_text_file: None,
write_text_file: None,
terminal: None,
}
}
}
@@ -320,6 +511,7 @@ pub trait Connection: Sized {
#[async_trait]
pub trait Session {
fn session_id(&self) -> &sacp::schema::SessionId;
fn notifications(&self) -> Vec<Notification>;
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
async fn prompt_with_image(
&mut self,
+18 -1
View File
@@ -14,17 +14,20 @@ use goose::permission::{Permission, PermissionConfirmation};
use goose::providers::base::Provider;
use goose::providers::errors::ProviderError;
use goose_test_support::{ExpectedSessionId, IgnoreSessionId, TEST_MODEL};
use sacp::schema::{AuthMethod, McpServer, ToolCallStatus};
use sacp::schema::{AuthMethod, McpServer, SessionUpdate, ToolCallStatus};
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::Mutex;
pub type NotificationSink = Arc<std::sync::Mutex<Vec<SessionUpdate>>>;
#[allow(dead_code)]
pub struct ClientToProviderConnection {
provider: Arc<Mutex<AcpProvider>>,
permission_manager: Arc<PermissionManager>,
auth_methods: Vec<AuthMethod>,
session_counter: usize,
notification_sink: NotificationSink,
_openai: OpenAiFixture,
_temp_dir: Option<tempfile::TempDir>,
_cwd: Option<tempfile::TempDir>,
@@ -34,6 +37,7 @@ pub struct ClientToProviderConnection {
pub struct ClientToProviderSession {
provider: Arc<Mutex<AcpProvider>>,
session_id: sacp::schema::SessionId,
notification_sink: NotificationSink,
}
impl ClientToProviderSession {
@@ -41,6 +45,7 @@ impl ClientToProviderSession {
async fn send_message(&mut self, message: Message, decision: PermissionDecision) -> TestOutput {
let session_id = self.session_id.0.clone();
let provider = self.provider.lock().await;
self.notification_sink.lock().unwrap().clear();
let model_config = provider.get_model_config();
let mut stream = provider
.stream(&model_config, &session_id, "", &[message], &[])
@@ -135,6 +140,8 @@ impl Connection for ClientToProviderConnection {
.map(|td| td.path().to_path_buf())
.unwrap_or(data_root);
let notification_sink: NotificationSink = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink_clone = notification_sink.clone();
let provider_config = AcpProviderConfig {
command: "unused".into(),
args: vec![],
@@ -144,6 +151,9 @@ impl Connection for ClientToProviderConnection {
mcp_servers,
session_mode_id: None,
permission_mapping: PermissionMapping::default(),
notification_callback: Some(Arc::new(move |n| {
sink_clone.lock().unwrap().push(n.update.clone());
})),
};
let provider = AcpProvider::connect_with_transport(
@@ -164,6 +174,7 @@ impl Connection for ClientToProviderConnection {
permission_manager,
auth_methods,
session_counter: 0,
notification_sink,
_openai: openai,
_temp_dir: temp_dir,
_cwd: config.cwd,
@@ -186,6 +197,7 @@ impl Connection for ClientToProviderConnection {
let session = ClientToProviderSession {
provider: Arc::clone(&self.provider),
session_id: sacp::schema::SessionId::new(goose_id),
notification_sink: self.notification_sink.clone(),
};
SessionResult {
session,
@@ -249,6 +261,11 @@ impl Session for ClientToProviderSession {
&self.session_id
}
fn notifications(&self) -> Vec<super::Notification> {
let updates = self.notification_sink.lock().unwrap();
super::to_notifications(&updates)
}
async fn prompt(&mut self, prompt: &str, decision: PermissionDecision) -> TestOutput {
self.send_message(Message::user().with_text(prompt), decision)
.await
+88 -5
View File
@@ -6,10 +6,12 @@ use async_trait::async_trait;
use goose::config::PermissionManager;
use goose_test_support::{EnforceSessionId, ExpectedSessionId};
use sacp::schema::{
AuthMethod, ClientCapabilities, ContentBlock, FileSystemCapability, ImageContent,
InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PromptRequest,
ProtocolVersion, ReadTextFileRequest, RequestPermissionRequest, SessionNotification,
SessionUpdate, StopReason, TextContent, ToolCallStatus, WriteTextFileRequest,
AuthMethod, ClientCapabilities, ContentBlock, CreateTerminalRequest, FileSystemCapability,
ImageContent, InitializeRequest, KillTerminalCommandRequest, LoadSessionRequest, McpServer,
NewSessionRequest, PromptRequest, ProtocolVersion, ReadTextFileRequest, ReleaseTerminalRequest,
RequestPermissionRequest, SessionNotification, SessionUpdate, StopReason,
TerminalOutputRequest, TextContent, ToolCallStatus, WaitForTerminalExitRequest,
WriteTextFileRequest,
};
use sacp::{ClientToAgent, JrConnectionCx};
use std::sync::{Arc, Mutex};
@@ -36,6 +38,7 @@ pub struct ClientToAgentSession {
updates: Arc<Mutex<Vec<SessionNotification>>>,
permission: Arc<Mutex<PermissionDecision>>,
notify: Arc<Notify>,
_work_dir: tempfile::TempDir,
}
impl ClientToAgentSession {
@@ -125,6 +128,7 @@ impl Connection for ClientToAgentConnection {
let permission_clone = permission.clone();
let read_handler = config.read_text_file;
let write_handler = config.write_text_file;
let terminal = config.terminal;
let cx_holder: Arc<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
Arc::new(Mutex::new(None));
@@ -185,6 +189,68 @@ impl Connection for ClientToAgentConnection {
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: CreateTerminalRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_create(&req.command)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: WaitForTerminalExitRequest, request_cx, _cx| match t {
Some(ref f) => {
request_cx.respond(f.on_wait_for_exit(&req.terminal_id))
}
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: TerminalOutputRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_output(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: ReleaseTerminalRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_release(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.on_receive_request(
{
let t = terminal.clone();
async move |req: KillTerminalCommandRequest, request_cx, _cx| match t {
Some(ref f) => request_cx.respond(f.on_kill(&req.terminal_id)),
None => {
request_cx.respond_with_error(sacp::Error::method_not_found())
}
}
},
sacp::on_receive_request!(),
)
.connect_to(transport)
.unwrap()
.run_until({
@@ -194,7 +260,11 @@ impl Connection for ClientToAgentConnection {
let resp = cx
.send_request(
InitializeRequest::new(ProtocolVersion::LATEST)
.client_capabilities(ClientCapabilities::new().fs(fs_cap)),
.client_capabilities(
ClientCapabilities::new()
.fs(fs_cap)
.terminal(terminal.is_some()),
),
)
.block_task()
.await
@@ -252,6 +322,7 @@ impl Connection for ClientToAgentConnection {
updates: self.updates.clone(),
permission: self.permission.clone(),
notify: self.notify.clone(),
_work_dir: work_dir,
};
SessionResult {
session,
@@ -283,6 +354,7 @@ impl Connection for ClientToAgentConnection {
updates: self.updates.clone(),
permission: self.permission.clone(),
notify: self.notify.clone(),
_work_dir: work_dir,
};
SessionResult {
session,
@@ -336,6 +408,17 @@ impl Session for ClientToAgentSession {
&self.session_id
}
fn notifications(&self) -> Vec<super::Notification> {
let updates: Vec<_> = self
.updates
.lock()
.unwrap()
.iter()
.map(|n| n.update.clone())
.collect();
super::to_notifications(&updates)
}
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput {
self.send_prompt(vec![ContentBlock::Text(TextContent::new(text))], decision)
.await
+12 -1
View File
@@ -8,7 +8,7 @@ use common_tests::{
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_load_mode, run_load_model,
run_load_session_mcp, run_mode_set, run_model_list, run_model_set, run_permission_persistence,
run_prompt_basic, run_prompt_codemode, run_prompt_image, run_prompt_image_attachment,
run_prompt_mcp, run_prompt_skill,
run_prompt_mcp, run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
};
tests_mode_set_error!(ClientToProviderConnection);
@@ -107,3 +107,14 @@ fn test_prompt_mcp() {
fn test_prompt_skill() {
run_test(async { run_prompt_skill::<ClientToProviderConnection>().await });
}
#[test]
fn test_shell_terminal_false() {
run_test(async { run_shell_terminal_false::<ClientToProviderConnection>().await });
}
#[test]
#[ignore = "provider does not handle terminal delegation requests"]
fn test_shell_terminal_true() {
run_test(async { run_shell_terminal_true::<ClientToProviderConnection>().await });
}
+11 -1
View File
@@ -6,7 +6,7 @@ use common_tests::{
run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_load_mode, run_load_model,
run_load_session_mcp, run_mode_set, run_model_list, run_model_set, run_permission_persistence,
run_prompt_basic, run_prompt_codemode, run_prompt_image, run_prompt_image_attachment,
run_prompt_mcp, run_prompt_skill,
run_prompt_mcp, run_prompt_skill, run_shell_terminal_false, run_shell_terminal_true,
};
tests_mode_set_error!(ClientToAgentConnection);
@@ -100,3 +100,13 @@ fn test_prompt_mcp() {
fn test_prompt_skill() {
run_test(async { run_prompt_skill::<ClientToAgentConnection>().await });
}
#[test]
fn test_shell_terminal_false() {
run_test(async { run_shell_terminal_false::<ClientToAgentConnection>().await });
}
#[test]
fn test_shell_terminal_true() {
run_test(async { run_shell_terminal_true::<ClientToAgentConnection>().await });
}
@@ -0,0 +1,30 @@
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_JOtrY1Ec2KAB2a7gI6H6CWfs","type":"function","function":{"name":"shell","arguments":""}}],"refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"QvrA4B"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"fvwZUJVGPB"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"command"}}]},"finish_reason":null}],"usage":null,"obfuscation":"bHHxtU"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"GgtVJCU4"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"echo"}}]},"finish_reason":null}],"usage":null,"obfuscation":"DRTSpxLvb"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" test"}}]},"finish_reason":null}],"usage":null,"obfuscation":"IwvMxMcs"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-shell"}}]},"finish_reason":null}],"usage":null,"obfuscation":"y6LBASX"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-content"}}]},"finish_reason":null}],"usage":null,"obfuscation":"4bIL9"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-"}}]},"finish_reason":null}],"usage":null,"obfuscation":"cOYkwBZ6YQk9"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"987"}}]},"finish_reason":null}],"usage":null,"obfuscation":"2QASfrMS6w"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"65"}}]},"finish_reason":null}],"usage":null,"obfuscation":"IKmTYztxyp2"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"finish_reason":null}],"usage":null,"obfuscation":"tVHIYFZOIs"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"91dJqR0sRAl"}
data: {"id":"chatcmpl-DJqmdyg4XAGu7vN41PpMz4U8CMQhI","object":"chat.completion.chunk","created":1773623503,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":3392,"completion_tokens":284,"total_tokens":3676,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":256,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"aOmtxq2YavJWo1s"}
data: [DONE]
@@ -0,0 +1,21 @@
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"k3ql1"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"test"},"finish_reason":null}],"usage":null,"obfuscation":"fi2"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-shell"},"finish_reason":null}],"usage":null,"obfuscation":"l"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-content"},"finish_reason":null}],"usage":null,"obfuscation":"lV6xovjxNlG3ZDv"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"EzE9fO"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"987"},"finish_reason":null}],"usage":null,"obfuscation":"Ji6A"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"65"},"finish_reason":null}],"usage":null,"obfuscation":"lI6rL"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null,"obfuscation":"x"}
data: {"id":"chatcmpl-DJqmhw9icV2gtFchMIfmzQZxB4sAt","object":"chat.completion.chunk","created":1773623507,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":3443,"completion_tokens":9,"total_tokens":3452,"prompt_tokens_details":{"cached_tokens":3200,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":""}
data: [DONE]
+5 -1
View File
@@ -28,7 +28,6 @@ use crate::permission::{Permission, PermissionConfirmation};
use crate::providers::base::{MessageStream, PermissionRouting, Provider};
use crate::providers::errors::ProviderError;
#[derive(Clone, Debug)]
pub struct AcpProviderConfig {
pub command: PathBuf,
pub args: Vec<String>,
@@ -38,6 +37,7 @@ pub struct AcpProviderConfig {
pub mcp_servers: Vec<McpServer>,
pub session_mode_id: Option<String>,
pub permission_mapping: PermissionMapping,
pub notification_callback: Option<Arc<dyn Fn(SessionNotification) + Send + Sync>>,
}
enum ClientRequest {
@@ -522,12 +522,16 @@ impl AcpClientLoop {
goose_mode,
prompt_response_tx,
} = self;
let notification_callback = config.notification_callback.clone();
ClientToAgent::builder()
.on_receive_notification(
{
let prompt_response_tx = prompt_response_tx.clone();
async move |notification: SessionNotification, _cx| {
if let Some(ref cb) = notification_callback {
cb(notification.clone());
}
// stream() reads goose_mode at call time, so it must
// reflect any prior set_mode before the next prompt.
if let SessionUpdate::CurrentModeUpdate(update) = &notification.update {
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use std::sync::LazyLock;
use std::time::Duration;
use rmcp::model::{CallToolResult, Content};
@@ -59,38 +59,51 @@ fn resolve_login_shell_path() -> Option<String> {
std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string())
};
std::process::Command::new(&shell)
let mut child = std::process::Command::new(&shell)
.args(["-l", "-i", "-c", "echo $PATH"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()
.and_then(|output| {
if output.status.success() {
// Take the last non-empty line — interactive shells may emit
// extra output from profile scripts before our echo.
String::from_utf8_lossy(&output.stdout)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.filter(|path| !path.is_empty())
} else {
None
}
})
.spawn()
.ok()?;
let mut stdout = child.stdout.take()?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
use std::io::Read;
if stdout.read_to_end(&mut buf).is_ok() {
let _ = tx.send(buf);
}
});
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(buf) if child.wait().is_ok_and(|s| s.success()) => {
// Take the last non-empty line — interactive shells may emit
// extra output from profile scripts before our echo.
String::from_utf8_lossy(&buf)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.filter(|path| !path.is_empty())
}
_ => {
let _ = child.kill();
let _ = child.wait();
None
}
}
}
/// Returns the user's full login shell PATH, resolved once and cached.
#[cfg(not(windows))]
fn user_login_path() -> Option<&'static str> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED.get_or_init(resolve_login_shell_path).as_deref()
}
static LOGIN_PATH: LazyLock<Option<String>> = LazyLock::new(resolve_login_shell_path);
pub struct ShellTool {
output_dir: tempfile::TempDir,
call_index: AtomicUsize,
#[cfg(not(windows))]
login_path: Option<String>,
}
impl ShellTool {
@@ -98,6 +111,18 @@ impl ShellTool {
Ok(Self {
output_dir: tempfile::tempdir()?,
call_index: AtomicUsize::new(0),
#[cfg(not(windows))]
login_path: LOGIN_PATH.clone(),
})
}
#[cfg(test)]
pub fn new_for_test() -> std::io::Result<Self> {
Ok(Self {
output_dir: tempfile::tempdir()?,
call_index: AtomicUsize::new(0),
#[cfg(not(windows))]
login_path: None,
})
}
@@ -114,7 +139,19 @@ impl ShellTool {
return Self::error_result("Command cannot be empty.", None);
}
let execution = match run_command(&params.command, params.timeout_secs, working_dir).await {
#[cfg(not(windows))]
let login_path = self.login_path.as_deref();
#[cfg(windows)]
let login_path: Option<&str> = None;
let execution = match run_command(
&params.command,
params.timeout_secs,
working_dir,
login_path,
)
.await
{
Ok(execution) => execution,
Err(error) => return Self::error_result(&error, None),
};
@@ -226,14 +263,14 @@ async fn run_command(
command_line: &str,
timeout_secs: Option<u64>,
working_dir: Option<&std::path::Path>,
login_path: Option<&str>,
) -> Result<ExecutionOutput, String> {
let mut command = build_shell_command(command_line);
if let Some(path) = working_dir {
command.current_dir(path);
}
#[cfg(not(windows))]
if let Some(path) = user_login_path() {
if let Some(path) = login_path {
command.env("PATH", path);
}
@@ -468,7 +505,7 @@ mod tests {
#[tokio::test]
async fn shell_executes_command() {
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
let result = tool
.shell(ShellParams {
command: "echo hello".to_string(),
@@ -483,7 +520,7 @@ mod tests {
#[cfg(not(windows))]
#[tokio::test]
async fn shell_returns_error_for_non_zero_exit() {
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
let result = tool
.shell(ShellParams {
command: "echo fail && exit 7".to_string(),
@@ -499,7 +536,7 @@ mod tests {
#[tokio::test]
async fn shell_uses_working_dir_for_relative_execution() {
let dir = tempfile::tempdir().unwrap();
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
let result = tool
.shell_with_cwd(
ShellParams {
@@ -598,7 +635,7 @@ mod tests {
#[test]
fn call_index_cycles_through_slots() {
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
for _cycle in 0..3 {
for expected in 0..OUTPUT_SLOTS {
let slot = tool.call_index.fetch_add(1, Ordering::Relaxed) % OUTPUT_SLOTS;
@@ -609,7 +646,7 @@ mod tests {
#[test]
fn concurrent_calls_get_distinct_slots() {
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
let mut slots: Vec<usize> = (0..OUTPUT_SLOTS)
.map(|_| tool.call_index.fetch_add(1, Ordering::Relaxed) % OUTPUT_SLOTS)
.collect();
@@ -630,7 +667,7 @@ mod tests {
}
}
let tool = ShellTool::new().unwrap();
let tool = ShellTool::new_for_test().unwrap();
let start = std::time::Instant::now();
let result = tool
.shell(ShellParams {
+1
View File
@@ -61,6 +61,7 @@ impl ProviderDef for ClaudeAcpProvider {
mcp_servers: extension_configs_to_mcp_servers(&extensions),
session_mode_id: Some(map_goose_mode(goose_mode)),
permission_mapping,
notification_callback: None,
};
let metadata = Self::metadata();
+1
View File
@@ -84,6 +84,7 @@ impl ProviderDef for CodexAcpProvider {
// Disabled until https://github.com/zed-industries/codex-acp/issues/179 is fixed.
session_mode_id: None,
permission_mapping,
notification_callback: None,
};
let metadata = Self::metadata();