Skip to repository content8701 lines · 297.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:33:08.070Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
mod.rs
1use super::*;
2use acp_thread::{
3 AgentConnection, AgentModelGroupName, AgentModelList, ClientUserMessageId, PermissionOptions,
4 ThreadStatus,
5};
6use agent_client_protocol::schema::v1 as acp;
7use agent_settings::AgentProfileId;
8use anyhow::Result;
9use client::{Client, RefreshLlmTokenListener, UserStore};
10use collections::IndexMap;
11use context_server::{ContextServer, ContextServerCommand, ContextServerId};
12use feature_flags::FeatureFlagAppExt as _;
13use fs::{FakeFs, Fs};
14use futures::{
15 FutureExt as _, StreamExt,
16 channel::{
17 mpsc::{self, UnboundedReceiver},
18 oneshot,
19 },
20 future::{Fuse, Shared},
21};
22use gpui::{
23 App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal,
24 http_client::FakeHttpClient,
25};
26use indoc::indoc;
27use language_model::{
28 CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
29 LanguageModelId, LanguageModelImageExt, LanguageModelProviderId, LanguageModelProviderName,
30 LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
31 LanguageModelToolResult, LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent,
32 Role, StopReason, TokenUsage,
33 fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
34};
35use pretty_assertions::assert_eq;
36use project::{
37 Project, context_server_store::ContextServerStore, project_settings::ProjectSettings,
38};
39use prompt_store::ProjectContext;
40use reqwest_client::ReqwestClient;
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43use serde_json::json;
44use settings::{LanguageModelProviderSetting, LanguageModelSelection, Settings, SettingsStore};
45use std::{
46 path::Path,
47 pin::Pin,
48 rc::Rc,
49 sync::{
50 Arc,
51 atomic::{AtomicBool, AtomicUsize, Ordering},
52 },
53 time::Duration,
54};
55use util::path;
56
57mod test_tools;
58use test_tools::*;
59
60pub(crate) fn init_test(cx: &mut TestAppContext) {
61 cx.update(|cx| {
62 let settings_store = SettingsStore::test(cx);
63 cx.set_global(settings_store);
64 });
65}
66
67pub(crate) struct FakeTerminalHandle {
68 killed: Arc<AtomicBool>,
69 stopped_by_user: Arc<AtomicBool>,
70 exit_sender: std::cell::RefCell<Option<futures::channel::oneshot::Sender<()>>>,
71 wait_for_exit: Shared<Task<acp::TerminalExitStatus>>,
72 output: acp::TerminalOutputResponse,
73 id: acp::TerminalId,
74}
75
76impl FakeTerminalHandle {
77 pub(crate) fn new_never_exits(cx: &mut App) -> Self {
78 let killed = Arc::new(AtomicBool::new(false));
79 let stopped_by_user = Arc::new(AtomicBool::new(false));
80
81 let (exit_sender, exit_receiver) = futures::channel::oneshot::channel();
82
83 let wait_for_exit = cx
84 .spawn(async move |_cx| {
85 // Wait for the exit signal (sent when kill() is called)
86 let _ = exit_receiver.await;
87 acp::TerminalExitStatus::new()
88 })
89 .shared();
90
91 Self {
92 killed,
93 stopped_by_user,
94 exit_sender: std::cell::RefCell::new(Some(exit_sender)),
95 wait_for_exit,
96 output: acp::TerminalOutputResponse::new("partial output".to_string(), false),
97 id: acp::TerminalId::new("fake_terminal".to_string()),
98 }
99 }
100
101 pub(crate) fn new_with_immediate_exit(cx: &mut App, exit_code: u32) -> Self {
102 let killed = Arc::new(AtomicBool::new(false));
103 let stopped_by_user = Arc::new(AtomicBool::new(false));
104 let (exit_sender, _exit_receiver) = futures::channel::oneshot::channel();
105
106 let wait_for_exit = cx
107 .spawn(async move |_cx| acp::TerminalExitStatus::new().exit_code(exit_code))
108 .shared();
109
110 Self {
111 killed,
112 stopped_by_user,
113 exit_sender: std::cell::RefCell::new(Some(exit_sender)),
114 wait_for_exit,
115 output: acp::TerminalOutputResponse::new("command output".to_string(), false),
116 id: acp::TerminalId::new("fake_terminal".to_string()),
117 }
118 }
119
120 pub(crate) fn with_output(mut self, output: acp::TerminalOutputResponse) -> Self {
121 self.output = output;
122 self
123 }
124
125 pub(crate) fn was_killed(&self) -> bool {
126 self.killed.load(Ordering::SeqCst)
127 }
128
129 pub(crate) fn set_stopped_by_user(&self, stopped: bool) {
130 self.stopped_by_user.store(stopped, Ordering::SeqCst);
131 }
132
133 pub(crate) fn signal_exit(&self) {
134 if let Some(sender) = self.exit_sender.borrow_mut().take() {
135 let _ = sender.send(());
136 }
137 }
138}
139
140impl crate::TerminalHandle for FakeTerminalHandle {
141 fn id(&self, _cx: &AsyncApp) -> Result<acp::TerminalId> {
142 Ok(self.id.clone())
143 }
144
145 fn current_output(&self, _cx: &AsyncApp) -> Result<acp::TerminalOutputResponse> {
146 Ok(self.output.clone())
147 }
148
149 fn wait_for_exit(&self, _cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
150 Ok(self.wait_for_exit.clone())
151 }
152
153 fn kill(&self, _cx: &AsyncApp) -> Result<()> {
154 self.killed.store(true, Ordering::SeqCst);
155 self.signal_exit();
156 Ok(())
157 }
158
159 fn was_stopped_by_user(&self, _cx: &AsyncApp) -> Result<bool> {
160 Ok(self.stopped_by_user.load(Ordering::SeqCst))
161 }
162}
163
164struct FakeSubagentHandle {
165 session_id: acp::SessionId,
166 send_task: Shared<Task<String>>,
167}
168
169impl SubagentHandle for FakeSubagentHandle {
170 fn id(&self) -> acp::SessionId {
171 self.session_id.clone()
172 }
173
174 fn num_entries(&self, _cx: &App) -> usize {
175 unimplemented!()
176 }
177
178 fn send(&self, _message: String, cx: &AsyncApp) -> Task<Result<String>> {
179 let task = self.send_task.clone();
180 cx.background_spawn(async move { Ok(task.await) })
181 }
182}
183
184#[derive(Default)]
185pub(crate) struct FakeThreadEnvironment {
186 terminal_handle: Option<Rc<FakeTerminalHandle>>,
187 subagent_handle: Option<Rc<FakeSubagentHandle>>,
188 terminal_creations: Arc<AtomicUsize>,
189 terminal_output_limits: std::cell::RefCell<Vec<Option<u64>>>,
190}
191
192impl FakeThreadEnvironment {
193 pub(crate) fn with_terminal(self, terminal_handle: FakeTerminalHandle) -> Self {
194 Self {
195 terminal_handle: Some(terminal_handle.into()),
196 ..self
197 }
198 }
199
200 pub(crate) fn terminal_creation_count(&self) -> usize {
201 self.terminal_creations.load(Ordering::SeqCst)
202 }
203
204 pub(crate) fn terminal_output_limits(&self) -> Vec<Option<u64>> {
205 self.terminal_output_limits.borrow().clone()
206 }
207}
208
209impl crate::ThreadEnvironment for FakeThreadEnvironment {
210 fn create_terminal(
211 &self,
212 _command: String,
213 _extra_env: Vec<acp::EnvVariable>,
214 _cwd: Option<std::path::PathBuf>,
215 output_byte_limit: Option<u64>,
216 _sandbox_wrap: Option<acp_thread::SandboxWrap>,
217 _cx: &mut AsyncApp,
218 ) -> Task<Result<Rc<dyn crate::TerminalHandle>>> {
219 self.terminal_creations.fetch_add(1, Ordering::SeqCst);
220 self.terminal_output_limits
221 .borrow_mut()
222 .push(output_byte_limit);
223 let handle = self
224 .terminal_handle
225 .clone()
226 .expect("Terminal handle not available on FakeThreadEnvironment");
227 Task::ready(Ok(handle as Rc<dyn crate::TerminalHandle>))
228 }
229
230 fn create_subagent(&self, _label: String, _cx: &mut App) -> Result<Rc<dyn SubagentHandle>> {
231 Ok(self
232 .subagent_handle
233 .clone()
234 .expect("Subagent handle not available on FakeThreadEnvironment")
235 as Rc<dyn SubagentHandle>)
236 }
237}
238
239/// Environment that creates multiple independent terminal handles for testing concurrent terminals.
240struct MultiTerminalEnvironment {
241 handles: std::cell::RefCell<Vec<Rc<FakeTerminalHandle>>>,
242}
243
244impl MultiTerminalEnvironment {
245 fn new() -> Self {
246 Self {
247 handles: std::cell::RefCell::new(Vec::new()),
248 }
249 }
250
251 fn handles(&self) -> Vec<Rc<FakeTerminalHandle>> {
252 self.handles.borrow().clone()
253 }
254}
255
256impl crate::ThreadEnvironment for MultiTerminalEnvironment {
257 fn create_terminal(
258 &self,
259 _command: String,
260 _extra_env: Vec<acp::EnvVariable>,
261 _cwd: Option<std::path::PathBuf>,
262 _output_byte_limit: Option<u64>,
263 _sandbox_wrap: Option<acp_thread::SandboxWrap>,
264 cx: &mut AsyncApp,
265 ) -> Task<Result<Rc<dyn crate::TerminalHandle>>> {
266 let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx)));
267 self.handles.borrow_mut().push(handle.clone());
268 Task::ready(Ok(handle as Rc<dyn crate::TerminalHandle>))
269 }
270
271 fn create_subagent(&self, _label: String, _cx: &mut App) -> Result<Rc<dyn SubagentHandle>> {
272 unimplemented!()
273 }
274}
275
276fn always_allow_tools(cx: &mut TestAppContext) {
277 cx.update(|cx| {
278 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
279 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
280 agent_settings::AgentSettings::override_global(settings, cx);
281 });
282}
283
284/// Turns terminal sandboxing off so the non-sandboxed `TerminalTool` is the
285/// variant exposed to the model as `terminal`. Tests that register
286/// `TerminalTool` directly need this because sandboxing is enabled by default
287/// for staff (and in debug builds), in which case `Thread::enabled_tools`
288/// would otherwise expose `SandboxedTerminalTool` under that name instead.
289fn disable_sandboxing(cx: &mut TestAppContext) {
290 cx.update(|cx| {
291 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
292 settings.sandbox_permissions.allow_unsandboxed = true;
293 agent_settings::AgentSettings::override_global(settings, cx);
294 });
295}
296
297#[gpui::test]
298async fn test_echo(cx: &mut TestAppContext) {
299 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
300 let fake_model = model.as_fake();
301
302 let events = thread
303 .update(cx, |thread, cx| {
304 thread.send(
305 ClientUserMessageId::new(),
306 ["Testing: Reply with 'Hello'"],
307 cx,
308 )
309 })
310 .unwrap();
311 cx.run_until_parked();
312 fake_model.send_last_completion_stream_text_chunk("Hello");
313 fake_model
314 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
315 fake_model.end_last_completion_stream();
316
317 let events = events.collect().await;
318 thread.update(cx, |thread, _cx| {
319 assert_eq!(
320 thread.last_received_or_pending_message().unwrap().role(),
321 Role::Assistant
322 );
323 assert_eq!(
324 thread
325 .last_received_or_pending_message()
326 .unwrap()
327 .to_markdown(),
328 "Hello\n"
329 )
330 });
331 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
332}
333
334#[gpui::test]
335async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) {
336 init_test(cx);
337 always_allow_tools(cx);
338
339 let fs = FakeFs::new(cx.executor());
340 let project = Project::test(fs, [], cx).await;
341
342 let environment = Rc::new(cx.update(|cx| {
343 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
344 }));
345 let handle = environment.terminal_handle.clone().unwrap();
346
347 #[allow(clippy::arc_with_non_send_sync)]
348 let tool = Arc::new(crate::TerminalTool::new(project, environment));
349 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
350
351 let task = cx.update(|cx| {
352 tool.run(
353 ToolInput::resolved(crate::TerminalToolInput {
354 command: "sleep 1000".to_string(),
355 cd: ".".to_string(),
356 timeout_ms: Some(5),
357 ..Default::default()
358 }),
359 event_stream,
360 cx,
361 )
362 });
363
364 let update = rx.expect_update_fields().await;
365 assert!(
366 update.content.iter().any(|blocks| {
367 blocks
368 .iter()
369 .any(|c| matches!(c, acp::ToolCallContent::Terminal(_)))
370 }),
371 "expected tool call update to include terminal content"
372 );
373
374 let mut task_future: Pin<Box<Fuse<Task<Result<String, String>>>>> = Box::pin(task.fuse());
375
376 let deadline = std::time::Instant::now() + Duration::from_millis(500);
377 loop {
378 if let Some(result) = task_future.as_mut().now_or_never() {
379 let result = result.expect("terminal tool task should complete");
380
381 assert!(
382 handle.was_killed(),
383 "expected terminal handle to be killed on timeout"
384 );
385 assert!(
386 result.contains("partial output"),
387 "expected result to include terminal output, got: {result}"
388 );
389 return;
390 }
391
392 if std::time::Instant::now() >= deadline {
393 panic!("timed out waiting for terminal tool task to complete");
394 }
395
396 cx.run_until_parked();
397 cx.background_executor.timer(Duration::from_millis(1)).await;
398 }
399}
400
401#[gpui::test]
402#[ignore]
403async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) {
404 init_test(cx);
405 always_allow_tools(cx);
406
407 let fs = FakeFs::new(cx.executor());
408 let project = Project::test(fs, [], cx).await;
409
410 let environment = Rc::new(cx.update(|cx| {
411 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
412 }));
413 let handle = environment.terminal_handle.clone().unwrap();
414
415 #[allow(clippy::arc_with_non_send_sync)]
416 let tool = Arc::new(crate::TerminalTool::new(project, environment));
417 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
418
419 let _task = cx.update(|cx| {
420 tool.run(
421 ToolInput::resolved(crate::TerminalToolInput {
422 command: "sleep 1000".to_string(),
423 cd: ".".to_string(),
424 timeout_ms: None,
425 ..Default::default()
426 }),
427 event_stream,
428 cx,
429 )
430 });
431
432 let update = rx.expect_update_fields().await;
433 assert!(
434 update.content.iter().any(|blocks| {
435 blocks
436 .iter()
437 .any(|c| matches!(c, acp::ToolCallContent::Terminal(_)))
438 }),
439 "expected tool call update to include terminal content"
440 );
441
442 cx.background_executor
443 .timer(Duration::from_millis(25))
444 .await;
445
446 assert!(
447 !handle.was_killed(),
448 "did not expect terminal handle to be killed without a timeout"
449 );
450}
451
452#[gpui::test]
453async fn test_thinking(cx: &mut TestAppContext) {
454 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
455 let fake_model = model.as_fake();
456
457 let events = thread
458 .update(cx, |thread, cx| {
459 thread.send(
460 ClientUserMessageId::new(),
461 [indoc! {"
462 Testing:
463
464 Generate a thinking step where you just think the word 'Think',
465 and have your final answer be 'Hello'
466 "}],
467 cx,
468 )
469 })
470 .unwrap();
471 cx.run_until_parked();
472 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Thinking {
473 text: "Think".to_string(),
474 signature: None,
475 });
476 fake_model.send_last_completion_stream_text_chunk("Hello");
477 fake_model
478 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
479 fake_model.end_last_completion_stream();
480
481 let events = events.collect().await;
482 thread.update(cx, |thread, _cx| {
483 assert_eq!(
484 thread.last_received_or_pending_message().unwrap().role(),
485 Role::Assistant
486 );
487 assert_eq!(
488 thread
489 .last_received_or_pending_message()
490 .unwrap()
491 .to_markdown(),
492 indoc! {"
493 <think>Think</think>
494 Hello
495 "}
496 )
497 });
498 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
499}
500
501#[gpui::test]
502async fn test_thinking_allowed_when_model_cannot_disable_thinking(cx: &mut TestAppContext) {
503 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
504 let fake_model = model.as_fake();
505 fake_model.set_supports_thinking(true);
506
507 // With thinking toggled off, a model that can disable thinking honors
508 // the toggle...
509 thread.update(cx, |thread, cx| {
510 thread.set_thinking_enabled(false, cx);
511 let request = thread
512 .build_completion_request(CompletionIntent::UserPrompt, cx)
513 .unwrap();
514 assert!(!request.thinking_allowed);
515 });
516
517 // ...but a model that always thinks ignores the stale toggle state.
518 fake_model.set_supports_disabling_thinking(false);
519 thread.update(cx, |thread, cx| {
520 let request = thread
521 .build_completion_request(CompletionIntent::UserPrompt, cx)
522 .unwrap();
523 assert!(request.thinking_allowed);
524 });
525}
526
527#[gpui::test]
528async fn test_system_prompt(cx: &mut TestAppContext) {
529 let ThreadTest {
530 model,
531 thread,
532 project_context,
533 ..
534 } = setup(cx, TestModel::Fake).await;
535 let fake_model = model.as_fake();
536
537 project_context.update(cx, |project_context, _cx| {
538 project_context.shell = "test-shell".into()
539 });
540 thread.update(cx, |thread, _| thread.add_tool(EchoTool));
541 thread
542 .update(cx, |thread, cx| {
543 thread.send(ClientUserMessageId::new(), ["abc"], cx)
544 })
545 .unwrap();
546 cx.run_until_parked();
547 let mut pending_completions = fake_model.pending_completions();
548 assert_eq!(
549 pending_completions.len(),
550 1,
551 "unexpected pending completions: {:?}",
552 pending_completions
553 );
554
555 let pending_completion = pending_completions.pop().unwrap();
556 assert_eq!(pending_completion.messages[0].role, Role::System);
557
558 let system_message = &pending_completion.messages[0];
559 let MessageContent::Text(system_prompt) = &system_message.content[0] else {
560 panic!("Expected text content");
561 };
562 assert!(
563 system_prompt.contains("test-shell"),
564 "unexpected system message: {:?}",
565 system_message
566 );
567 assert!(
568 system_prompt.contains("## Fixing Diagnostics"),
569 "unexpected system message: {:?}",
570 system_message
571 );
572}
573
574#[gpui::test]
575async fn test_system_prompt_without_tools(cx: &mut TestAppContext) {
576 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
577 let fake_model = model.as_fake();
578
579 thread
580 .update(cx, |thread, cx| {
581 thread.send(ClientUserMessageId::new(), ["abc"], cx)
582 })
583 .unwrap();
584 cx.run_until_parked();
585 let mut pending_completions = fake_model.pending_completions();
586 assert_eq!(
587 pending_completions.len(),
588 1,
589 "unexpected pending completions: {:?}",
590 pending_completions
591 );
592
593 let pending_completion = pending_completions.pop().unwrap();
594 assert_eq!(pending_completion.messages[0].role, Role::System);
595
596 let system_message = &pending_completion.messages[0];
597 let MessageContent::Text(system_prompt) = &system_message.content[0] else {
598 panic!("Expected text content");
599 };
600 assert!(
601 !system_prompt.contains("## Tool Use"),
602 "unexpected system message: {:?}",
603 system_message
604 );
605 assert!(
606 !system_prompt.contains("## Fixing Diagnostics"),
607 "unexpected system message: {:?}",
608 system_message
609 );
610}
611
612#[gpui::test]
613async fn test_prompt_caching(cx: &mut TestAppContext) {
614 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
615 let fake_model = model.as_fake();
616
617 // Send initial user message and verify it's cached
618 thread
619 .update(cx, |thread, cx| {
620 thread.send(ClientUserMessageId::new(), ["Message 1"], cx)
621 })
622 .unwrap();
623 cx.run_until_parked();
624
625 let completion = fake_model.pending_completions().pop().unwrap();
626 assert_eq!(
627 completion.messages[1..],
628 vec![LanguageModelRequestMessage {
629 role: Role::User,
630 content: vec!["Message 1".into()],
631 cache: true,
632 reasoning_details: None,
633 }]
634 );
635 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
636 "Response to Message 1".into(),
637 ));
638 fake_model.end_last_completion_stream();
639 cx.run_until_parked();
640
641 // Send another user message and verify only the latest is cached
642 thread
643 .update(cx, |thread, cx| {
644 thread.send(ClientUserMessageId::new(), ["Message 2"], cx)
645 })
646 .unwrap();
647 cx.run_until_parked();
648
649 let completion = fake_model.pending_completions().pop().unwrap();
650 assert_eq!(
651 completion.messages[1..],
652 vec![
653 LanguageModelRequestMessage {
654 role: Role::User,
655 content: vec!["Message 1".into()],
656 cache: false,
657 reasoning_details: None,
658 },
659 LanguageModelRequestMessage {
660 role: Role::Assistant,
661 content: vec!["Response to Message 1".into()],
662 cache: false,
663 reasoning_details: None,
664 },
665 LanguageModelRequestMessage {
666 role: Role::User,
667 content: vec!["Message 2".into()],
668 cache: true,
669 reasoning_details: None,
670 }
671 ]
672 );
673 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
674 "Response to Message 2".into(),
675 ));
676 fake_model.end_last_completion_stream();
677 cx.run_until_parked();
678
679 // Simulate a tool call and verify that the latest tool result is cached
680 thread.update(cx, |thread, _| thread.add_tool(EchoTool));
681 thread
682 .update(cx, |thread, cx| {
683 thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx)
684 })
685 .unwrap();
686 cx.run_until_parked();
687
688 let tool_use = LanguageModelToolUse {
689 id: "tool_1".into(),
690 name: EchoTool::NAME.into(),
691 raw_input: json!({"text": "test"}).to_string(),
692 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
693 is_input_complete: true,
694 thought_signature: None,
695 };
696 fake_model
697 .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
698 fake_model.end_last_completion_stream();
699 cx.run_until_parked();
700
701 let completion = fake_model.pending_completions().pop().unwrap();
702 let tool_result = LanguageModelToolResult {
703 tool_use_id: "tool_1".into(),
704 tool_name: EchoTool::NAME.into(),
705 is_error: false,
706 content: vec!["test".into()],
707 output: Some("test".into()),
708 };
709 assert_eq!(
710 completion.messages[1..],
711 vec![
712 LanguageModelRequestMessage {
713 role: Role::User,
714 content: vec!["Message 1".into()],
715 cache: false,
716 reasoning_details: None,
717 },
718 LanguageModelRequestMessage {
719 role: Role::Assistant,
720 content: vec!["Response to Message 1".into()],
721 cache: false,
722 reasoning_details: None,
723 },
724 LanguageModelRequestMessage {
725 role: Role::User,
726 content: vec!["Message 2".into()],
727 cache: false,
728 reasoning_details: None,
729 },
730 LanguageModelRequestMessage {
731 role: Role::Assistant,
732 content: vec!["Response to Message 2".into()],
733 cache: false,
734 reasoning_details: None,
735 },
736 LanguageModelRequestMessage {
737 role: Role::User,
738 content: vec!["Use the echo tool".into()],
739 cache: false,
740 reasoning_details: None,
741 },
742 LanguageModelRequestMessage {
743 role: Role::Assistant,
744 content: vec![MessageContent::ToolUse(tool_use)],
745 cache: false,
746 reasoning_details: None,
747 },
748 LanguageModelRequestMessage {
749 role: Role::User,
750 content: vec![MessageContent::ToolResult(tool_result)],
751 cache: true,
752 reasoning_details: None,
753 }
754 ]
755 );
756}
757
758#[gpui::test]
759#[cfg_attr(not(feature = "e2e"), ignore)]
760async fn test_basic_tool_calls(cx: &mut TestAppContext) {
761 let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
762
763 // Test a tool call that's likely to complete *before* streaming stops.
764 let events = thread
765 .update(cx, |thread, cx| {
766 thread.add_tool(EchoTool);
767 thread.send(
768 ClientUserMessageId::new(),
769 ["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."],
770 cx,
771 )
772 })
773 .unwrap()
774 .collect()
775 .await;
776 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
777
778 // Test a tool calls that's likely to complete *after* streaming stops.
779 let events = thread
780 .update(cx, |thread, cx| {
781 thread.remove_tool(&EchoTool::NAME);
782 thread.add_tool(DelayTool);
783 thread.send(
784 ClientUserMessageId::new(),
785 [
786 "Now call the delay tool with 200ms.",
787 "When the timer goes off, then you echo the output of the tool.",
788 ],
789 cx,
790 )
791 })
792 .unwrap()
793 .collect()
794 .await;
795 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
796 thread.update(cx, |thread, _cx| {
797 assert!(
798 thread
799 .last_received_or_pending_message()
800 .unwrap()
801 .as_agent_message()
802 .unwrap()
803 .content
804 .iter()
805 .any(|content| {
806 if let AgentMessageContent::Text(text) = content {
807 text.contains("Ding")
808 } else {
809 false
810 }
811 }),
812 "{}",
813 thread.to_markdown()
814 );
815 });
816}
817
818#[gpui::test]
819#[cfg_attr(not(feature = "e2e"), ignore)]
820async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
821 let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
822
823 // Test a tool call that's likely to complete *before* streaming stops.
824 let mut events = thread
825 .update(cx, |thread, cx| {
826 thread.add_tool(WordListTool);
827 thread.send(ClientUserMessageId::new(), ["Test the word_list tool."], cx)
828 })
829 .unwrap();
830
831 let mut saw_partial_tool_use = false;
832 while let Some(event) = events.next().await {
833 if let Ok(ThreadEvent::ToolCall(tool_call)) = event {
834 thread.update(cx, |thread, _cx| {
835 // Look for a tool use in the thread's last message
836 let message = thread.last_received_or_pending_message().unwrap();
837 let agent_message = message.as_agent_message().unwrap();
838 let last_content = agent_message.content.last().unwrap();
839 if let AgentMessageContent::ToolUse(last_tool_use) = last_content {
840 assert_eq!(last_tool_use.name.as_ref(), "word_list");
841 if tool_call.status == acp::ToolCallStatus::Pending {
842 if !last_tool_use.is_input_complete
843 && last_tool_use
844 .input
845 .as_json()
846 .and_then(|input| input.get("g"))
847 .is_none()
848 {
849 saw_partial_tool_use = true;
850 }
851 } else {
852 last_tool_use
853 .input
854 .as_json()
855 .expect("tool input should be JSON")
856 .get("a")
857 .expect("'a' has streamed because input is now complete");
858 last_tool_use
859 .input
860 .as_json()
861 .expect("tool input should be JSON")
862 .get("g")
863 .expect("'g' has streamed because input is now complete");
864 }
865 } else {
866 panic!("last content should be a tool use");
867 }
868 });
869 }
870 }
871
872 assert!(
873 saw_partial_tool_use,
874 "should see at least one partially streamed tool use in the history"
875 );
876}
877
878#[gpui::test]
879async fn test_tool_authorization(cx: &mut TestAppContext) {
880 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
881 let fake_model = model.as_fake();
882
883 let mut events = thread
884 .update(cx, |thread, cx| {
885 thread.add_tool(ToolRequiringPermission);
886 thread.send(ClientUserMessageId::new(), ["abc"], cx)
887 })
888 .unwrap();
889 cx.run_until_parked();
890 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
891 LanguageModelToolUse {
892 id: "tool_id_1".into(),
893 name: ToolRequiringPermission::NAME.into(),
894 raw_input: "{}".into(),
895 input: language_model::LanguageModelToolUseInput::Json(json!({})),
896 is_input_complete: true,
897 thought_signature: None,
898 },
899 ));
900 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
901 LanguageModelToolUse {
902 id: "tool_id_2".into(),
903 name: ToolRequiringPermission::NAME.into(),
904 raw_input: "{}".into(),
905 input: language_model::LanguageModelToolUseInput::Json(json!({})),
906 is_input_complete: true,
907 thought_signature: None,
908 },
909 ));
910 fake_model.end_last_completion_stream();
911 let tool_call_auth_1 = next_tool_call_authorization(&mut events).await;
912 let tool_call_auth_2 = next_tool_call_authorization(&mut events).await;
913
914 // Approve the first - send "allow" option_id (UI transforms "once" to "allow")
915 tool_call_auth_1
916 .response
917 .send(acp_thread::SelectedPermissionOutcome::new(
918 acp::PermissionOptionId::new("allow"),
919 acp::PermissionOptionKind::AllowOnce,
920 ))
921 .unwrap();
922 cx.run_until_parked();
923
924 // Reject the second - send "deny" option_id directly since Deny is now a button
925 tool_call_auth_2
926 .response
927 .send(acp_thread::SelectedPermissionOutcome::new(
928 acp::PermissionOptionId::new("deny"),
929 acp::PermissionOptionKind::RejectOnce,
930 ))
931 .unwrap();
932 cx.run_until_parked();
933
934 let completion = fake_model.pending_completions().pop().unwrap();
935 let message = completion.messages.last().unwrap();
936 assert_eq!(
937 message.content,
938 vec![
939 language_model::MessageContent::ToolResult(LanguageModelToolResult {
940 tool_use_id: "tool_id_1".into(),
941 tool_name: ToolRequiringPermission::NAME.into(),
942 is_error: false,
943 content: vec!["Allowed".into()],
944 output: Some("Allowed".into())
945 }),
946 language_model::MessageContent::ToolResult(LanguageModelToolResult {
947 tool_use_id: "tool_id_2".into(),
948 tool_name: ToolRequiringPermission::NAME.into(),
949 is_error: true,
950 content: vec!["Permission to run tool denied by user".into()],
951 output: Some("Permission to run tool denied by user".into())
952 })
953 ]
954 );
955
956 // Simulate yet another tool call.
957 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
958 LanguageModelToolUse {
959 id: "tool_id_3".into(),
960 name: ToolRequiringPermission::NAME.into(),
961 raw_input: "{}".into(),
962 input: language_model::LanguageModelToolUseInput::Json(json!({})),
963 is_input_complete: true,
964 thought_signature: None,
965 },
966 ));
967 fake_model.end_last_completion_stream();
968
969 // Respond by always allowing tools - send transformed option_id
970 // (UI transforms "always:tool_requiring_permission" to "always_allow:tool_requiring_permission")
971 let tool_call_auth_3 = next_tool_call_authorization(&mut events).await;
972 tool_call_auth_3
973 .response
974 .send(acp_thread::SelectedPermissionOutcome::new(
975 acp::PermissionOptionId::new("always_allow:tool_requiring_permission"),
976 acp::PermissionOptionKind::AllowAlways,
977 ))
978 .unwrap();
979 cx.run_until_parked();
980 let completion = fake_model.pending_completions().pop().unwrap();
981 let message = completion.messages.last().unwrap();
982 assert_eq!(
983 message.content,
984 vec![language_model::MessageContent::ToolResult(
985 LanguageModelToolResult {
986 tool_use_id: "tool_id_3".into(),
987 tool_name: ToolRequiringPermission::NAME.into(),
988 is_error: false,
989 content: vec!["Allowed".into()],
990 output: Some("Allowed".into())
991 }
992 )]
993 );
994
995 // Simulate a final tool call, ensuring we don't trigger authorization.
996 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
997 LanguageModelToolUse {
998 id: "tool_id_4".into(),
999 name: ToolRequiringPermission::NAME.into(),
1000 raw_input: "{}".into(),
1001 input: language_model::LanguageModelToolUseInput::Json(json!({})),
1002 is_input_complete: true,
1003 thought_signature: None,
1004 },
1005 ));
1006 fake_model.end_last_completion_stream();
1007 cx.run_until_parked();
1008 let completion = fake_model.pending_completions().pop().unwrap();
1009 let message = completion.messages.last().unwrap();
1010 assert_eq!(
1011 message.content,
1012 vec![language_model::MessageContent::ToolResult(
1013 LanguageModelToolResult {
1014 tool_use_id: "tool_id_4".into(),
1015 tool_name: ToolRequiringPermission::NAME.into(),
1016 is_error: false,
1017 content: vec!["Allowed".into()],
1018 output: Some("Allowed".into())
1019 }
1020 )]
1021 );
1022}
1023
1024#[gpui::test]
1025async fn test_tool_hallucination(cx: &mut TestAppContext) {
1026 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
1027 let fake_model = model.as_fake();
1028
1029 let mut events = thread
1030 .update(cx, |thread, cx| {
1031 thread.send(ClientUserMessageId::new(), ["abc"], cx)
1032 })
1033 .unwrap();
1034 cx.run_until_parked();
1035 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1036 LanguageModelToolUse {
1037 id: "tool_id_1".into(),
1038 name: "nonexistent_tool".into(),
1039 raw_input: "{}".into(),
1040 input: language_model::LanguageModelToolUseInput::Json(json!({})),
1041 is_input_complete: true,
1042 thought_signature: None,
1043 },
1044 ));
1045 fake_model.end_last_completion_stream();
1046
1047 let tool_call = expect_tool_call(&mut events).await;
1048 assert_eq!(tool_call.title, "nonexistent_tool");
1049 assert_eq!(tool_call.status, acp::ToolCallStatus::Pending);
1050 let update = expect_tool_call_update_fields(&mut events).await;
1051 assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed));
1052}
1053
1054/// Regression test: some providers (confirmed on Bedrock Mantle/GPT-5.x)
1055/// reset their raw `tool_use` id counter every request/response cycle, so
1056/// the same id (e.g. `call_1`) can recur within one turn. Used verbatim as
1057/// the ACP id, this let a later tool call overwrite an earlier, unrelated
1058/// one in `AcpThread::upsert_tool_call`.
1059#[gpui::test]
1060async fn test_tool_call_id_scoped_per_completion_request(cx: &mut TestAppContext) {
1061 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
1062 let fake_model = model.as_fake();
1063
1064 thread.update(cx, |thread, _cx| {
1065 thread.add_tool(EchoTool);
1066 });
1067
1068 let mut events = thread
1069 .update(cx, |thread, cx| {
1070 thread.send(ClientUserMessageId::new(), ["Use the echo tool twice"], cx)
1071 })
1072 .unwrap();
1073 cx.run_until_parked();
1074
1075 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1076 LanguageModelToolUse {
1077 id: "call_1".into(),
1078 name: EchoTool::NAME.into(),
1079 raw_input: json!({"text": "first"}).to_string(),
1080 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "first"})),
1081 is_input_complete: true,
1082 thought_signature: None,
1083 },
1084 ));
1085 fake_model.end_last_completion_stream();
1086 let first_tool_call = next_tool_call(&mut events).await;
1087 cx.run_until_parked();
1088
1089 // Same turn, second cycle: the id counter has reset, so "call_1" recurs
1090 // for a different tool call.
1091 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1092 LanguageModelToolUse {
1093 id: "call_1".into(),
1094 name: EchoTool::NAME.into(),
1095 raw_input: json!({"text": "second"}).to_string(),
1096 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "second"})),
1097 is_input_complete: true,
1098 thought_signature: None,
1099 },
1100 ));
1101 fake_model.end_last_completion_stream();
1102 let second_tool_call = next_tool_call(&mut events).await;
1103 cx.run_until_parked();
1104
1105 fake_model
1106 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
1107 fake_model.end_last_completion_stream();
1108 events.collect::<Vec<_>>().await;
1109
1110 assert_ne!(
1111 first_tool_call.tool_call_id, second_tool_call.tool_call_id,
1112 "raw tool_use ids that recur across separate completion requests within the same turn \
1113 must map to distinct ACP tool call ids, otherwise the second tool call would overwrite \
1114 the first instead of appearing as a new entry"
1115 );
1116}
1117
1118/// Same bug as `test_tool_call_id_scoped_per_completion_request`, but
1119/// exercised through persistence and `Thread::replay()` instead of live
1120/// streaming.
1121#[gpui::test]
1122async fn test_replayed_tool_call_ids_scoped_across_messages(cx: &mut TestAppContext) {
1123 let ThreadTest {
1124 model,
1125 thread,
1126 project_context,
1127 ..
1128 } = setup(cx, TestModel::Fake).await;
1129 let fake_model = model.as_fake();
1130
1131 thread.update(cx, |thread, _cx| {
1132 thread.add_tool(EchoTool);
1133 });
1134
1135 thread
1136 .update(cx, |thread, cx| {
1137 thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx)
1138 })
1139 .unwrap();
1140 cx.run_until_parked();
1141 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1142 LanguageModelToolUse {
1143 id: "call_1".into(),
1144 name: EchoTool::NAME.into(),
1145 raw_input: json!({"text": "first"}).to_string(),
1146 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "first"})),
1147 is_input_complete: true,
1148 thought_signature: None,
1149 },
1150 ));
1151 fake_model.end_last_completion_stream();
1152 cx.run_until_parked();
1153 fake_model.send_last_completion_stream_text_chunk("Done with first");
1154 fake_model.end_last_completion_stream();
1155 cx.run_until_parked();
1156
1157 // Different turn: the id counter has reset, so "call_1" recurs in a
1158 // different `AgentMessage`.
1159 thread
1160 .update(cx, |thread, cx| {
1161 thread.send(ClientUserMessageId::new(), ["Use the echo tool again"], cx)
1162 })
1163 .unwrap();
1164 cx.run_until_parked();
1165 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1166 LanguageModelToolUse {
1167 id: "call_1".into(),
1168 name: EchoTool::NAME.into(),
1169 raw_input: json!({"text": "second"}).to_string(),
1170 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "second"})),
1171 is_input_complete: true,
1172 thought_signature: None,
1173 },
1174 ));
1175 fake_model.end_last_completion_stream();
1176 cx.run_until_parked();
1177 fake_model.send_last_completion_stream_text_chunk("Done with second");
1178 fake_model.end_last_completion_stream();
1179 cx.run_until_parked();
1180
1181 let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await;
1182
1183 cx.update(|cx| {
1184 LanguageModelRegistry::test(cx);
1185 });
1186 let restored = cx.update(|cx| {
1187 let thread = thread.read(cx);
1188 let project = thread.project.clone();
1189 let context_server_registry = thread.context_server_registry.clone();
1190 let templates = thread.templates.clone();
1191 cx.new(|cx| {
1192 Thread::from_db(
1193 acp::SessionId::new("restored"),
1194 db_thread,
1195 project,
1196 project_context.clone(),
1197 context_server_registry,
1198 templates,
1199 cx,
1200 )
1201 })
1202 });
1203 restored.update(cx, |thread, _cx| {
1204 thread.add_tool(EchoTool);
1205 });
1206
1207 let mut replay_events = restored.update(cx, |thread, cx| thread.replay(cx));
1208 let mut tool_call_ids = Vec::new();
1209 while let Some(event) = replay_events.next().await {
1210 if let ThreadEvent::ToolCall(tool_call) = event.unwrap() {
1211 tool_call_ids.push(tool_call.tool_call_id);
1212 }
1213 }
1214
1215 assert_eq!(
1216 tool_call_ids.len(),
1217 2,
1218 "expected one replayed tool call per message"
1219 );
1220 assert_ne!(
1221 tool_call_ids[0], tool_call_ids[1],
1222 "raw tool_use ids that recur across separate AgentMessages must map to distinct ACP \
1223 tool call ids when replayed from a persisted thread, otherwise the second tool call \
1224 would overwrite the first instead of appearing as a new entry"
1225 );
1226}
1227
1228async fn expect_tool_call(events: &mut UnboundedReceiver<Result<ThreadEvent>>) -> acp::ToolCall {
1229 let event = events
1230 .next()
1231 .await
1232 .expect("no tool call authorization event received")
1233 .unwrap();
1234 match event {
1235 ThreadEvent::ToolCall(tool_call) => tool_call,
1236 event => {
1237 panic!("Unexpected event {event:?}");
1238 }
1239 }
1240}
1241
1242/// Like [`expect_tool_call`], but skips other events until a `ToolCall`
1243/// appears -- useful across multiple request/response cycles in one turn.
1244async fn next_tool_call(events: &mut UnboundedReceiver<Result<ThreadEvent>>) -> acp::ToolCall {
1245 loop {
1246 let event = events
1247 .next()
1248 .await
1249 .expect("no tool call event received")
1250 .unwrap();
1251 if let ThreadEvent::ToolCall(tool_call) = event {
1252 return tool_call;
1253 }
1254 }
1255}
1256
1257async fn expect_tool_call_update_fields(
1258 events: &mut UnboundedReceiver<Result<ThreadEvent>>,
1259) -> acp::ToolCallUpdate {
1260 let event = events
1261 .next()
1262 .await
1263 .expect("no tool call authorization event received")
1264 .unwrap();
1265 match event {
1266 ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) => update,
1267 event => {
1268 panic!("Unexpected event {event:?}");
1269 }
1270 }
1271}
1272
1273async fn next_tool_call_authorization(
1274 events: &mut UnboundedReceiver<Result<ThreadEvent>>,
1275) -> ToolCallAuthorization {
1276 loop {
1277 let event = events
1278 .next()
1279 .await
1280 .expect("no tool call authorization event received")
1281 .unwrap();
1282 if let ThreadEvent::ToolCallAuthorization(tool_call_authorization) = event {
1283 let permission_kinds = tool_call_authorization
1284 .options
1285 .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
1286 .map(|option| option.kind);
1287 let allow_once = tool_call_authorization
1288 .options
1289 .first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
1290 .map(|option| option.kind);
1291
1292 assert_eq!(
1293 permission_kinds,
1294 Some(acp::PermissionOptionKind::AllowAlways)
1295 );
1296 assert_eq!(allow_once, Some(acp::PermissionOptionKind::AllowOnce));
1297 return tool_call_authorization;
1298 }
1299 }
1300}
1301
1302#[test]
1303fn test_permission_options_terminal_with_pattern() {
1304 let permission_options = ToolPermissionContext::new(
1305 TerminalTool::NAME,
1306 vec!["cargo build --release".to_string()],
1307 )
1308 .build_permission_options();
1309
1310 let PermissionOptions::Dropdown(choices) = permission_options else {
1311 panic!("Expected dropdown permission options");
1312 };
1313
1314 assert_eq!(choices.len(), 3);
1315 let labels: Vec<&str> = choices
1316 .iter()
1317 .map(|choice| choice.allow.name.as_ref())
1318 .collect();
1319 assert!(labels.contains(&"Always for terminal"));
1320 assert!(labels.contains(&"Always for `cargo build` commands"));
1321 assert!(labels.contains(&"Only this time"));
1322}
1323
1324#[test]
1325fn test_permission_options_terminal_command_with_flag_second_token() {
1326 let permission_options =
1327 ToolPermissionContext::new(TerminalTool::NAME, vec!["ls -la".to_string()])
1328 .build_permission_options();
1329
1330 let PermissionOptions::Dropdown(choices) = permission_options else {
1331 panic!("Expected dropdown permission options");
1332 };
1333
1334 assert_eq!(choices.len(), 3);
1335 let labels: Vec<&str> = choices
1336 .iter()
1337 .map(|choice| choice.allow.name.as_ref())
1338 .collect();
1339 assert!(labels.contains(&"Always for terminal"));
1340 assert!(labels.contains(&"Always for `ls` commands"));
1341 assert!(labels.contains(&"Only this time"));
1342}
1343
1344#[test]
1345fn test_permission_options_terminal_single_word_command() {
1346 let permission_options =
1347 ToolPermissionContext::new(TerminalTool::NAME, vec!["whoami".to_string()])
1348 .build_permission_options();
1349
1350 let PermissionOptions::Dropdown(choices) = permission_options else {
1351 panic!("Expected dropdown permission options");
1352 };
1353
1354 assert_eq!(choices.len(), 3);
1355 let labels: Vec<&str> = choices
1356 .iter()
1357 .map(|choice| choice.allow.name.as_ref())
1358 .collect();
1359 assert!(labels.contains(&"Always for terminal"));
1360 assert!(labels.contains(&"Always for `whoami` commands"));
1361 assert!(labels.contains(&"Only this time"));
1362}
1363
1364#[test]
1365fn test_permission_options_edit_file_with_path_pattern() {
1366 let permission_options =
1367 ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
1368 .build_permission_options();
1369
1370 let PermissionOptions::Dropdown(choices) = permission_options else {
1371 panic!("Expected dropdown permission options");
1372 };
1373
1374 let labels: Vec<&str> = choices
1375 .iter()
1376 .map(|choice| choice.allow.name.as_ref())
1377 .collect();
1378 assert!(labels.contains(&"Always for edit file"));
1379 assert!(labels.contains(&"Always for `src/`"));
1380}
1381
1382#[test]
1383fn test_permission_options_fetch_with_domain_pattern() {
1384 let permission_options =
1385 ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
1386 .build_permission_options();
1387
1388 let PermissionOptions::Dropdown(choices) = permission_options else {
1389 panic!("Expected dropdown permission options");
1390 };
1391
1392 let labels: Vec<&str> = choices
1393 .iter()
1394 .map(|choice| choice.allow.name.as_ref())
1395 .collect();
1396 assert!(labels.contains(&"Always for fetch"));
1397 assert!(labels.contains(&"Always for `docs.rs`"));
1398}
1399
1400#[test]
1401fn test_permission_options_without_pattern() {
1402 let permission_options = ToolPermissionContext::new(
1403 TerminalTool::NAME,
1404 vec!["./deploy.sh --production".to_string()],
1405 )
1406 .build_permission_options();
1407
1408 let PermissionOptions::Dropdown(choices) = permission_options else {
1409 panic!("Expected dropdown permission options");
1410 };
1411
1412 assert_eq!(choices.len(), 2);
1413 let labels: Vec<&str> = choices
1414 .iter()
1415 .map(|choice| choice.allow.name.as_ref())
1416 .collect();
1417 assert!(labels.contains(&"Always for terminal"));
1418 assert!(labels.contains(&"Only this time"));
1419 assert!(!labels.iter().any(|label| label.contains("commands")));
1420}
1421
1422#[test]
1423fn test_permission_options_symlink_target_are_flat_once_only() {
1424 let permission_options =
1425 ToolPermissionContext::symlink_target(EditFileTool::NAME, vec!["/outside/file.txt".into()])
1426 .build_permission_options();
1427
1428 let PermissionOptions::Flat(options) = permission_options else {
1429 panic!("Expected flat permission options for symlink target authorization");
1430 };
1431
1432 assert_eq!(options.len(), 2);
1433 assert!(options.iter().any(|option| {
1434 option.option_id.0.as_ref() == "allow"
1435 && option.kind == acp::PermissionOptionKind::AllowOnce
1436 }));
1437 assert!(options.iter().any(|option| {
1438 option.option_id.0.as_ref() == "deny"
1439 && option.kind == acp::PermissionOptionKind::RejectOnce
1440 }));
1441}
1442
1443#[test]
1444fn test_permission_option_ids_for_terminal() {
1445 let permission_options = ToolPermissionContext::new(
1446 TerminalTool::NAME,
1447 vec!["cargo build --release".to_string()],
1448 )
1449 .build_permission_options();
1450
1451 let PermissionOptions::Dropdown(choices) = permission_options else {
1452 panic!("Expected dropdown permission options");
1453 };
1454
1455 // Expect 3 choices: always-tool, always-pattern, once
1456 assert_eq!(choices.len(), 3);
1457
1458 // First two choices both use the tool-level option IDs
1459 assert_eq!(
1460 choices[0].allow.option_id.0.as_ref(),
1461 "always_allow:terminal"
1462 );
1463 assert_eq!(choices[0].deny.option_id.0.as_ref(), "always_deny:terminal");
1464 assert!(choices[0].sub_patterns.is_empty());
1465
1466 assert_eq!(
1467 choices[1].allow.option_id.0.as_ref(),
1468 "always_allow:terminal"
1469 );
1470 assert_eq!(choices[1].deny.option_id.0.as_ref(), "always_deny:terminal");
1471 assert_eq!(choices[1].sub_patterns, vec!["^cargo\\s+build(\\s|$)"]);
1472
1473 // Third choice is the one-time allow/deny
1474 assert_eq!(choices[2].allow.option_id.0.as_ref(), "allow");
1475 assert_eq!(choices[2].deny.option_id.0.as_ref(), "deny");
1476 assert!(choices[2].sub_patterns.is_empty());
1477}
1478
1479#[test]
1480fn test_permission_options_terminal_pipeline_produces_dropdown_with_patterns() {
1481 let permission_options = ToolPermissionContext::new(
1482 TerminalTool::NAME,
1483 vec!["cargo test 2>&1 | tail".to_string()],
1484 )
1485 .build_permission_options();
1486
1487 let PermissionOptions::DropdownWithPatterns {
1488 choices,
1489 patterns,
1490 tool_name,
1491 } = permission_options
1492 else {
1493 panic!("Expected DropdownWithPatterns permission options for pipeline command");
1494 };
1495
1496 assert_eq!(tool_name, TerminalTool::NAME);
1497
1498 // Should have "Always for terminal" and "Only this time" choices
1499 assert_eq!(choices.len(), 2);
1500 let labels: Vec<&str> = choices
1501 .iter()
1502 .map(|choice| choice.allow.name.as_ref())
1503 .collect();
1504 assert!(labels.contains(&"Always for terminal"));
1505 assert!(labels.contains(&"Only this time"));
1506
1507 // Should have per-command patterns for "cargo test" and "tail"
1508 assert_eq!(patterns.len(), 2);
1509 let pattern_names: Vec<&str> = patterns.iter().map(|cp| cp.display_name.as_str()).collect();
1510 assert!(pattern_names.contains(&"cargo test"));
1511 assert!(pattern_names.contains(&"tail"));
1512
1513 // Verify patterns are valid regex patterns
1514 let regex_patterns: Vec<&str> = patterns.iter().map(|cp| cp.pattern.as_str()).collect();
1515 assert!(regex_patterns.contains(&"^cargo\\s+test(\\s|$)"));
1516 assert!(regex_patterns.contains(&"^tail\\b"));
1517}
1518
1519#[test]
1520fn test_permission_options_terminal_pipeline_with_chaining() {
1521 let permission_options = ToolPermissionContext::new(
1522 TerminalTool::NAME,
1523 vec!["npm install && npm test | tail".to_string()],
1524 )
1525 .build_permission_options();
1526
1527 let PermissionOptions::DropdownWithPatterns { patterns, .. } = permission_options else {
1528 panic!("Expected DropdownWithPatterns for chained pipeline command");
1529 };
1530
1531 // With subcommand-aware patterns, "npm install" and "npm test" are distinct
1532 assert_eq!(patterns.len(), 3);
1533 let pattern_names: Vec<&str> = patterns.iter().map(|cp| cp.display_name.as_str()).collect();
1534 assert!(pattern_names.contains(&"npm install"));
1535 assert!(pattern_names.contains(&"npm test"));
1536 assert!(pattern_names.contains(&"tail"));
1537}
1538
1539#[gpui::test]
1540#[cfg_attr(not(feature = "e2e"), ignore)]
1541async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
1542 let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
1543
1544 // Test concurrent tool calls with different delay times
1545 let events = thread
1546 .update(cx, |thread, cx| {
1547 thread.add_tool(DelayTool);
1548 thread.send(
1549 ClientUserMessageId::new(),
1550 [
1551 "Call the delay tool twice in the same message.",
1552 "Once with 100ms. Once with 300ms.",
1553 "When both timers are complete, describe the outputs.",
1554 ],
1555 cx,
1556 )
1557 })
1558 .unwrap()
1559 .collect()
1560 .await;
1561
1562 let stop_reasons = stop_events(events);
1563 assert_eq!(stop_reasons, vec![acp::StopReason::EndTurn]);
1564
1565 thread.update(cx, |thread, _cx| {
1566 let last_message = thread.last_received_or_pending_message().unwrap();
1567 let agent_message = last_message.as_agent_message().unwrap();
1568 let text = agent_message
1569 .content
1570 .iter()
1571 .filter_map(|content| {
1572 if let AgentMessageContent::Text(text) = content {
1573 Some(text.as_str())
1574 } else {
1575 None
1576 }
1577 })
1578 .collect::<String>();
1579
1580 assert!(text.contains("Ding"));
1581 });
1582}
1583
1584#[gpui::test]
1585async fn test_profiles(cx: &mut TestAppContext) {
1586 let ThreadTest {
1587 model, thread, fs, ..
1588 } = setup(cx, TestModel::Fake).await;
1589 let fake_model = model.as_fake();
1590
1591 thread.update(cx, |thread, _cx| {
1592 thread.add_tool(DelayTool);
1593 thread.add_tool(EchoTool);
1594 thread.add_tool(InfiniteTool);
1595 });
1596
1597 // Override profiles and wait for settings to be loaded.
1598 fs.insert_file(
1599 paths::settings_file(),
1600 json!({
1601 "agent": {
1602 "profiles": {
1603 "test-1": {
1604 "name": "Test Profile 1",
1605 "tools": {
1606 EchoTool::NAME: true,
1607 DelayTool::NAME: true,
1608 }
1609 },
1610 "test-2": {
1611 "name": "Test Profile 2",
1612 "tools": {
1613 InfiniteTool::NAME: true,
1614 }
1615 }
1616 }
1617 }
1618 })
1619 .to_string()
1620 .into_bytes(),
1621 )
1622 .await;
1623 cx.run_until_parked();
1624
1625 // Test that test-1 profile (default) has echo and delay tools
1626 thread
1627 .update(cx, |thread, cx| {
1628 thread.set_profile(AgentProfileId("test-1".into()), cx);
1629 thread.send(ClientUserMessageId::new(), ["test"], cx)
1630 })
1631 .unwrap();
1632 cx.run_until_parked();
1633
1634 let mut pending_completions = fake_model.pending_completions();
1635 assert_eq!(pending_completions.len(), 1);
1636 let completion = pending_completions.pop().unwrap();
1637 let tool_names: Vec<String> = completion
1638 .tools
1639 .iter()
1640 .map(|tool| tool.name.clone())
1641 .collect();
1642 assert_eq!(tool_names, vec![DelayTool::NAME, EchoTool::NAME]);
1643 fake_model.end_last_completion_stream();
1644
1645 // Switch to test-2 profile, and verify that it has only the infinite tool.
1646 thread
1647 .update(cx, |thread, cx| {
1648 thread.set_profile(AgentProfileId("test-2".into()), cx);
1649 thread.send(ClientUserMessageId::new(), ["test2"], cx)
1650 })
1651 .unwrap();
1652 cx.run_until_parked();
1653 let mut pending_completions = fake_model.pending_completions();
1654 assert_eq!(pending_completions.len(), 1);
1655 let completion = pending_completions.pop().unwrap();
1656 let tool_names: Vec<String> = completion
1657 .tools
1658 .iter()
1659 .map(|tool| tool.name.clone())
1660 .collect();
1661 assert_eq!(tool_names, vec![InfiniteTool::NAME]);
1662}
1663
1664#[gpui::test]
1665async fn test_mcp_tools(cx: &mut TestAppContext) {
1666 let ThreadTest {
1667 model,
1668 thread,
1669 context_server_store,
1670 fs,
1671 ..
1672 } = setup(cx, TestModel::Fake).await;
1673 let fake_model = model.as_fake();
1674
1675 // Override profiles and wait for settings to be loaded.
1676 fs.insert_file(
1677 paths::settings_file(),
1678 json!({
1679 "agent": {
1680 "tool_permissions": { "default": "allow" },
1681 "profiles": {
1682 "test": {
1683 "name": "Test Profile",
1684 "enable_all_context_servers": true,
1685 "tools": {
1686 EchoTool::NAME: true,
1687 }
1688 },
1689 }
1690 }
1691 })
1692 .to_string()
1693 .into_bytes(),
1694 )
1695 .await;
1696 cx.run_until_parked();
1697 thread.update(cx, |thread, cx| {
1698 thread.set_profile(AgentProfileId("test".into()), cx)
1699 });
1700
1701 let mut mcp_tool_calls = setup_context_server(
1702 "test_server",
1703 vec![context_server::types::Tool {
1704 name: "echo".into(),
1705 title: None,
1706 description: None,
1707 input_schema: serde_json::to_value(EchoTool::input_schema(
1708 LanguageModelToolSchemaFormat::JsonSchema,
1709 ))
1710 .unwrap(),
1711 output_schema: None,
1712 annotations: None,
1713 }],
1714 &context_server_store,
1715 cx,
1716 );
1717
1718 let events = thread.update(cx, |thread, cx| {
1719 thread
1720 .send(ClientUserMessageId::new(), ["Hey"], cx)
1721 .unwrap()
1722 });
1723 cx.run_until_parked();
1724
1725 // Simulate the model calling the MCP tool.
1726 let completion = fake_model.pending_completions().pop().unwrap();
1727 assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
1728 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1729 LanguageModelToolUse {
1730 id: "tool_1".into(),
1731 name: "echo".into(),
1732 raw_input: json!({"text": "test"}).to_string(),
1733 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
1734 is_input_complete: true,
1735 thought_signature: None,
1736 },
1737 ));
1738 fake_model.end_last_completion_stream();
1739 cx.run_until_parked();
1740
1741 let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
1742 assert_eq!(tool_call_params.name, "echo");
1743 assert_eq!(tool_call_params.arguments, Some(json!({"text": "test"})));
1744 tool_call_response
1745 .send(context_server::types::CallToolResponse {
1746 content: vec![context_server::types::ToolResponseContent::Text {
1747 text: "test".into(),
1748 }],
1749 is_error: None,
1750 meta: None,
1751 structured_content: None,
1752 })
1753 .unwrap();
1754 cx.run_until_parked();
1755
1756 assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
1757 fake_model.send_last_completion_stream_text_chunk("Done!");
1758 fake_model.end_last_completion_stream();
1759 events.collect::<Vec<_>>().await;
1760
1761 // Send again after adding the echo tool, ensuring the name collision is resolved.
1762 let events = thread.update(cx, |thread, cx| {
1763 thread.add_tool(EchoTool);
1764 thread.send(ClientUserMessageId::new(), ["Go"], cx).unwrap()
1765 });
1766 cx.run_until_parked();
1767 let completion = fake_model.pending_completions().pop().unwrap();
1768 assert_eq!(
1769 tool_names_for_completion(&completion),
1770 vec!["echo", "test_server_echo"]
1771 );
1772 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1773 LanguageModelToolUse {
1774 id: "tool_2".into(),
1775 name: "test_server_echo".into(),
1776 raw_input: json!({"text": "mcp"}).to_string(),
1777 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})),
1778 is_input_complete: true,
1779 thought_signature: None,
1780 },
1781 ));
1782 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1783 LanguageModelToolUse {
1784 id: "tool_3".into(),
1785 name: "echo".into(),
1786 raw_input: json!({"text": "native"}).to_string(),
1787 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})),
1788 is_input_complete: true,
1789 thought_signature: None,
1790 },
1791 ));
1792 fake_model.end_last_completion_stream();
1793 cx.run_until_parked();
1794
1795 let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
1796 assert_eq!(tool_call_params.name, "echo");
1797 assert_eq!(tool_call_params.arguments, Some(json!({"text": "mcp"})));
1798 tool_call_response
1799 .send(context_server::types::CallToolResponse {
1800 content: vec![context_server::types::ToolResponseContent::Text { text: "mcp".into() }],
1801 is_error: None,
1802 meta: None,
1803 structured_content: None,
1804 })
1805 .unwrap();
1806 cx.run_until_parked();
1807
1808 // Ensure the tool results were inserted with the correct names.
1809 let completion = fake_model.pending_completions().pop().unwrap();
1810 assert_eq!(
1811 completion.messages.last().unwrap().content,
1812 vec![
1813 MessageContent::ToolResult(LanguageModelToolResult {
1814 tool_use_id: "tool_3".into(),
1815 tool_name: "echo".into(),
1816 is_error: false,
1817 content: vec!["native".into()],
1818 output: Some("native".into()),
1819 },),
1820 MessageContent::ToolResult(LanguageModelToolResult {
1821 tool_use_id: "tool_2".into(),
1822 tool_name: "test_server_echo".into(),
1823 is_error: false,
1824 content: vec!["mcp".into()],
1825 output: Some("mcp".into()),
1826 },),
1827 ]
1828 );
1829 fake_model.end_last_completion_stream();
1830 events.collect::<Vec<_>>().await;
1831}
1832
1833#[gpui::test]
1834async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext) {
1835 let ThreadTest {
1836 model,
1837 thread,
1838 context_server_store,
1839 fs,
1840 ..
1841 } = setup(cx, TestModel::Fake).await;
1842 let fake_model = model.as_fake();
1843
1844 fs.insert_file(
1845 paths::settings_file(),
1846 json!({
1847 "agent": {
1848 "tool_permissions": { "default": "allow" },
1849 "profiles": {
1850 "test": {
1851 "name": "Test Profile",
1852 "enable_all_context_servers": true,
1853 },
1854 }
1855 }
1856 })
1857 .to_string()
1858 .into_bytes(),
1859 )
1860 .await;
1861 cx.run_until_parked();
1862 thread.update(cx, |thread, cx| {
1863 thread.set_profile(AgentProfileId("test".into()), cx)
1864 });
1865
1866 let mut mcp_tool_calls = setup_context_server(
1867 "Superluminal",
1868 vec![context_server::types::Tool {
1869 name: "snake_case.PascalCase".into(),
1870 title: None,
1871 description: None,
1872 input_schema: json!({"type": "object", "properties": {}}),
1873 output_schema: None,
1874 annotations: None,
1875 }],
1876 &context_server_store,
1877 cx,
1878 );
1879
1880 let events = thread.update(cx, |thread, cx| {
1881 thread
1882 .send(ClientUserMessageId::new(), ["Use the MCP tool"], cx)
1883 .unwrap()
1884 });
1885 cx.run_until_parked();
1886
1887 let completion = fake_model.pending_completions().pop().unwrap();
1888 assert_eq!(
1889 tool_names_for_completion(&completion),
1890 vec!["snake_case_PascalCase"]
1891 );
1892 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1893 LanguageModelToolUse {
1894 id: "tool_1".into(),
1895 name: "snake_case_PascalCase".into(),
1896 raw_input: json!({}).to_string(),
1897 input: language_model::LanguageModelToolUseInput::Json(json!({})),
1898 is_input_complete: true,
1899 thought_signature: None,
1900 },
1901 ));
1902 fake_model.end_last_completion_stream();
1903 cx.run_until_parked();
1904
1905 let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
1906 assert_eq!(tool_call_params.name, "snake_case.PascalCase");
1907 tool_call_response
1908 .send(context_server::types::CallToolResponse {
1909 content: vec![context_server::types::ToolResponseContent::Text {
1910 text: "done".into(),
1911 }],
1912 is_error: None,
1913 meta: None,
1914 structured_content: None,
1915 })
1916 .unwrap();
1917 cx.run_until_parked();
1918
1919 fake_model.send_last_completion_stream_text_chunk("Done!");
1920 fake_model.end_last_completion_stream();
1921 events.collect::<Vec<_>>().await;
1922}
1923
1924#[gpui::test]
1925async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
1926 let ThreadTest {
1927 model,
1928 thread,
1929 context_server_store,
1930 fs,
1931 ..
1932 } = setup(cx, TestModel::Fake).await;
1933 let fake_model = model.as_fake();
1934 fake_model.set_supports_images(true);
1935
1936 fs.insert_file(
1937 paths::settings_file(),
1938 json!({
1939 "agent": {
1940 "tool_permissions": { "default": "allow" },
1941 "profiles": {
1942 "test": {
1943 "name": "Test Profile",
1944 "enable_all_context_servers": true,
1945 "tools": {}
1946 },
1947 }
1948 }
1949 })
1950 .to_string()
1951 .into_bytes(),
1952 )
1953 .await;
1954 cx.run_until_parked();
1955 thread.update(cx, |thread, cx| {
1956 thread.set_profile(AgentProfileId("test".into()), cx)
1957 });
1958
1959 let mut mcp_tool_calls = setup_context_server(
1960 "screenshot_server",
1961 vec![context_server::types::Tool {
1962 name: "screenshot".into(),
1963 title: None,
1964 description: None,
1965 input_schema: json!({"type": "object", "properties": {}}),
1966 output_schema: None,
1967 annotations: None,
1968 }],
1969 &context_server_store,
1970 cx,
1971 );
1972
1973 let events = thread.update(cx, |thread, cx| {
1974 thread
1975 .send(ClientUserMessageId::new(), ["Take a screenshot"], cx)
1976 .unwrap()
1977 });
1978 cx.run_until_parked();
1979
1980 let completion = fake_model.pending_completions().pop().unwrap();
1981 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
1982 LanguageModelToolUse {
1983 id: "tool_1".into(),
1984 name: "screenshot".into(),
1985 raw_input: json!({}).to_string(),
1986 input: language_model::LanguageModelToolUseInput::Json(json!({})),
1987 is_input_complete: true,
1988 thought_signature: None,
1989 },
1990 ));
1991 fake_model.end_last_completion_stream();
1992 cx.run_until_parked();
1993 let _ = completion;
1994
1995 let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
1996 assert_eq!(tool_call_params.name, "screenshot");
1997 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
1998 tool_call_response
1999 .send(context_server::types::CallToolResponse {
2000 content: vec![
2001 context_server::types::ToolResponseContent::Text {
2002 text: "Some text".into(),
2003 },
2004 context_server::types::ToolResponseContent::Image {
2005 data: image_data.into(),
2006 mime_type: "image/png".into(),
2007 },
2008 context_server::types::ToolResponseContent::Text {
2009 text: "Some more text".into(),
2010 },
2011 ],
2012 is_error: None,
2013 meta: None,
2014 structured_content: None,
2015 })
2016 .unwrap();
2017 cx.run_until_parked();
2018
2019 // Verify the tool result round-trips back to the model as a multi-part Vec.
2020 let completion = fake_model.pending_completions().pop().unwrap();
2021 let tool_result = completion
2022 .messages
2023 .last()
2024 .unwrap()
2025 .content
2026 .iter()
2027 .find_map(|c| match c {
2028 MessageContent::ToolResult(r) => Some(r.clone()),
2029 _ => None,
2030 })
2031 .expect("expected a tool result");
2032 assert_eq!(tool_result.tool_use_id, "tool_1".into());
2033 assert_eq!(tool_result.content.len(), 3);
2034 assert_eq!(
2035 tool_result.content[0],
2036 language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
2037 );
2038 let expected_image =
2039 language_model::LanguageModelImage::from_base64_image(image_data, "image/png")
2040 .expect("image conversion should not error")
2041 .expect("image conversion should succeed");
2042 assert_eq!(
2043 tool_result.content[0],
2044 language_model::LanguageModelToolResultContent::Text(Arc::from("Some text"))
2045 );
2046 assert_eq!(
2047 tool_result.content[1],
2048 language_model::LanguageModelToolResultContent::Image(expected_image)
2049 );
2050 assert_eq!(
2051 tool_result.content[2],
2052 language_model::LanguageModelToolResultContent::Text(Arc::from("Some more text"))
2053 );
2054 fake_model.end_last_completion_stream();
2055 events.collect::<Vec<_>>().await;
2056}
2057
2058#[gpui::test]
2059async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAppContext) {
2060 let ThreadTest {
2061 model,
2062 thread,
2063 context_server_store,
2064 fs,
2065 ..
2066 } = setup(cx, TestModel::Fake).await;
2067 let fake_model = model.as_fake();
2068
2069 // Setup settings to allow MCP tools
2070 fs.insert_file(
2071 paths::settings_file(),
2072 json!({
2073 "agent": {
2074 "always_allow_tool_actions": true,
2075 "profiles": {
2076 "test": {
2077 "name": "Test Profile",
2078 "enable_all_context_servers": true,
2079 "tools": {}
2080 },
2081 }
2082 }
2083 })
2084 .to_string()
2085 .into_bytes(),
2086 )
2087 .await;
2088 cx.run_until_parked();
2089 thread.update(cx, |thread, cx| {
2090 thread.set_profile(AgentProfileId("test".into()), cx)
2091 });
2092
2093 // Setup a context server with a tool
2094 let mut mcp_tool_calls = setup_context_server(
2095 "github_server",
2096 vec![context_server::types::Tool {
2097 name: "issue_read".into(),
2098 title: None,
2099 description: Some("Read a GitHub issue".into()),
2100 input_schema: json!({
2101 "type": "object",
2102 "properties": {
2103 "issue_url": { "type": "string" }
2104 }
2105 }),
2106 output_schema: None,
2107 annotations: None,
2108 }],
2109 &context_server_store,
2110 cx,
2111 );
2112
2113 // Send a message and have the model call the MCP tool
2114 let events = thread.update(cx, |thread, cx| {
2115 thread
2116 .send(ClientUserMessageId::new(), ["Read issue #47404"], cx)
2117 .unwrap()
2118 });
2119 cx.run_until_parked();
2120
2121 // Verify the MCP tool is available to the model
2122 let completion = fake_model.pending_completions().pop().unwrap();
2123 assert_eq!(
2124 tool_names_for_completion(&completion),
2125 vec!["issue_read"],
2126 "MCP tool should be available"
2127 );
2128
2129 // Simulate the model calling the MCP tool
2130 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2131 LanguageModelToolUse {
2132 id: "tool_1".into(),
2133 name: "issue_read".into(),
2134 raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"})
2135 .to_string(),
2136 input: language_model::LanguageModelToolUseInput::Json(
2137 json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
2138 ),
2139 is_input_complete: true,
2140 thought_signature: None,
2141 },
2142 ));
2143 fake_model.end_last_completion_stream();
2144 cx.run_until_parked();
2145
2146 // The MCP server receives the tool call and responds with content
2147 let expected_tool_output = "Issue #47404: Tool call results are cleared upon app close";
2148 let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
2149 assert_eq!(tool_call_params.name, "issue_read");
2150 tool_call_response
2151 .send(context_server::types::CallToolResponse {
2152 content: vec![context_server::types::ToolResponseContent::Text {
2153 text: expected_tool_output.into(),
2154 }],
2155 is_error: None,
2156 meta: None,
2157 structured_content: None,
2158 })
2159 .unwrap();
2160 cx.run_until_parked();
2161
2162 // After tool completes, the model continues with a new completion request
2163 // that includes the tool results. We need to respond to this.
2164 let _completion = fake_model.pending_completions().pop().unwrap();
2165 fake_model.send_last_completion_stream_text_chunk("I found the issue!");
2166 fake_model
2167 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
2168 fake_model.end_last_completion_stream();
2169 events.collect::<Vec<_>>().await;
2170
2171 // Verify the tool result is stored in the thread by checking the markdown output.
2172 // The tool result is in the first assistant message (not the last one, which is
2173 // the model's response after the tool completed).
2174 thread.update(cx, |thread, _cx| {
2175 let markdown = thread.to_markdown();
2176 assert!(
2177 markdown.contains("**Tool Result**: issue_read"),
2178 "Thread should contain tool result header"
2179 );
2180 assert!(
2181 markdown.contains(expected_tool_output),
2182 "Thread should contain tool output: {}",
2183 expected_tool_output
2184 );
2185 });
2186
2187 // Simulate app restart: disconnect the MCP server.
2188 // After restart, the MCP server won't be connected yet when the thread is replayed.
2189 context_server_store.update(cx, |store, cx| {
2190 let _ = store.stop_server(&ContextServerId("github_server".into()), cx);
2191 });
2192 cx.run_until_parked();
2193
2194 // Replay the thread (this is what happens when loading a saved thread)
2195 let mut replay_events = thread.update(cx, |thread, cx| thread.replay(cx));
2196
2197 let mut found_tool_call = None;
2198 let mut found_tool_call_update_with_output = None;
2199 // The ACP id is scoped by message index (see `scoped_tool_call_id`), so
2200 // capture it instead of assuming it matches the raw provider id.
2201 let mut tool_call_id = None;
2202
2203 while let Some(event) = replay_events.next().await {
2204 let event = event.unwrap();
2205 match &event {
2206 ThreadEvent::ToolCall(tc) => {
2207 tool_call_id = Some(tc.tool_call_id.clone());
2208 found_tool_call = Some(tc.clone());
2209 }
2210 ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update))
2211 if tool_call_id.as_ref() == Some(&update.tool_call_id) =>
2212 {
2213 if update.fields.raw_output.is_some() {
2214 found_tool_call_update_with_output = Some(update.clone());
2215 }
2216 }
2217 _ => {}
2218 }
2219 }
2220
2221 // The tool call should be found
2222 assert!(
2223 found_tool_call.is_some(),
2224 "Tool call should be emitted during replay"
2225 );
2226
2227 assert!(
2228 found_tool_call_update_with_output.is_some(),
2229 "ToolCallUpdate with raw_output should be emitted even when MCP server is disconnected."
2230 );
2231
2232 let update = found_tool_call_update_with_output.unwrap();
2233 assert_eq!(
2234 update.fields.raw_output,
2235 Some(expected_tool_output.into()),
2236 "raw_output should contain the saved tool result"
2237 );
2238
2239 // Also verify the status is correct (completed, not failed)
2240 assert_eq!(
2241 update.fields.status,
2242 Some(acp::ToolCallStatus::Completed),
2243 "Tool call status should reflect the original completion status"
2244 );
2245}
2246
2247#[gpui::test]
2248async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
2249 let ThreadTest {
2250 model,
2251 thread,
2252 context_server_store,
2253 fs,
2254 ..
2255 } = setup(cx, TestModel::Fake).await;
2256 let fake_model = model.as_fake();
2257
2258 // Set up a profile with all tools enabled
2259 fs.insert_file(
2260 paths::settings_file(),
2261 json!({
2262 "agent": {
2263 "profiles": {
2264 "test": {
2265 "name": "Test Profile",
2266 "enable_all_context_servers": true,
2267 "tools": {
2268 EchoTool::NAME: true,
2269 DelayTool::NAME: true,
2270 WordListTool::NAME: true,
2271 ToolRequiringPermission::NAME: true,
2272 InfiniteTool::NAME: true,
2273 }
2274 },
2275 }
2276 }
2277 })
2278 .to_string()
2279 .into_bytes(),
2280 )
2281 .await;
2282 cx.run_until_parked();
2283
2284 thread.update(cx, |thread, cx| {
2285 thread.set_profile(AgentProfileId("test".into()), cx);
2286 thread.add_tool(EchoTool);
2287 thread.add_tool(DelayTool);
2288 thread.add_tool(WordListTool);
2289 thread.add_tool(ToolRequiringPermission);
2290 thread.add_tool(InfiniteTool);
2291 });
2292
2293 // Set up multiple context servers with some overlapping tool names
2294 let _server1_calls = setup_context_server(
2295 "xxx",
2296 vec![
2297 context_server::types::Tool {
2298 name: "echo".into(), // Conflicts with native EchoTool
2299 title: None,
2300 description: None,
2301 input_schema: serde_json::to_value(EchoTool::input_schema(
2302 LanguageModelToolSchemaFormat::JsonSchema,
2303 ))
2304 .unwrap(),
2305 output_schema: None,
2306 annotations: None,
2307 },
2308 context_server::types::Tool {
2309 name: "unique_tool_1".into(),
2310 title: None,
2311 description: None,
2312 input_schema: json!({"type": "object", "properties": {}}),
2313 output_schema: None,
2314 annotations: None,
2315 },
2316 ],
2317 &context_server_store,
2318 cx,
2319 );
2320
2321 let _server2_calls = setup_context_server(
2322 "yyy",
2323 vec![
2324 context_server::types::Tool {
2325 name: "echo".into(), // Also conflicts with native EchoTool
2326 title: None,
2327 description: None,
2328 input_schema: serde_json::to_value(EchoTool::input_schema(
2329 LanguageModelToolSchemaFormat::JsonSchema,
2330 ))
2331 .unwrap(),
2332 output_schema: None,
2333 annotations: None,
2334 },
2335 context_server::types::Tool {
2336 name: "unique_tool_2".into(),
2337 title: None,
2338 description: None,
2339 input_schema: json!({"type": "object", "properties": {}}),
2340 output_schema: None,
2341 annotations: None,
2342 },
2343 context_server::types::Tool {
2344 name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
2345 title: None,
2346 description: None,
2347 input_schema: json!({"type": "object", "properties": {}}),
2348 output_schema: None,
2349 annotations: None,
2350 },
2351 context_server::types::Tool {
2352 name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
2353 title: None,
2354 description: None,
2355 input_schema: json!({"type": "object", "properties": {}}),
2356 output_schema: None,
2357 annotations: None,
2358 },
2359 ],
2360 &context_server_store,
2361 cx,
2362 );
2363 let _server3_calls = setup_context_server(
2364 "zzz",
2365 vec![
2366 context_server::types::Tool {
2367 name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
2368 title: None,
2369 description: None,
2370 input_schema: json!({"type": "object", "properties": {}}),
2371 output_schema: None,
2372 annotations: None,
2373 },
2374 context_server::types::Tool {
2375 name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
2376 title: None,
2377 description: None,
2378 input_schema: json!({"type": "object", "properties": {}}),
2379 output_schema: None,
2380 annotations: None,
2381 },
2382 context_server::types::Tool {
2383 name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1),
2384 title: None,
2385 description: None,
2386 input_schema: json!({"type": "object", "properties": {}}),
2387 output_schema: None,
2388 annotations: None,
2389 },
2390 ],
2391 &context_server_store,
2392 cx,
2393 );
2394
2395 // Server with spaces in name - tests snake_case conversion for API compatibility
2396 let _server4_calls = setup_context_server(
2397 "Azure DevOps",
2398 vec![context_server::types::Tool {
2399 name: "echo".into(), // Also conflicts - will be disambiguated as azure_dev_ops_echo
2400 title: None,
2401 description: None,
2402 input_schema: serde_json::to_value(EchoTool::input_schema(
2403 LanguageModelToolSchemaFormat::JsonSchema,
2404 ))
2405 .unwrap(),
2406 output_schema: None,
2407 annotations: None,
2408 }],
2409 &context_server_store,
2410 cx,
2411 );
2412
2413 thread
2414 .update(cx, |thread, cx| {
2415 thread.send(ClientUserMessageId::new(), ["Go"], cx)
2416 })
2417 .unwrap();
2418 cx.run_until_parked();
2419 let completion = fake_model.pending_completions().pop().unwrap();
2420 assert_eq!(
2421 tool_names_for_completion(&completion),
2422 vec![
2423 "azure_dev_ops_echo",
2424 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
2425 "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
2426 "delay",
2427 "echo",
2428 "infinite",
2429 "tool_requiring_permission",
2430 "unique_tool_1",
2431 "unique_tool_2",
2432 "word_list",
2433 "xxx_echo",
2434 "y_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2435 "yyy_echo",
2436 "z_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2437 ]
2438 );
2439}
2440
2441#[gpui::test]
2442#[cfg_attr(not(feature = "e2e"), ignore)]
2443async fn test_cancellation(cx: &mut TestAppContext) {
2444 let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await;
2445
2446 let mut events = thread
2447 .update(cx, |thread, cx| {
2448 thread.add_tool(InfiniteTool);
2449 thread.add_tool(EchoTool);
2450 thread.send(
2451 ClientUserMessageId::new(),
2452 ["Call the echo tool, then call the infinite tool, then explain their output"],
2453 cx,
2454 )
2455 })
2456 .unwrap();
2457
2458 // Wait until both tools are called.
2459 let mut expected_tools = vec!["Echo", "Infinite Tool"];
2460 let mut echo_id = None;
2461 let mut echo_completed = false;
2462 while let Some(event) = events.next().await {
2463 match event.unwrap() {
2464 ThreadEvent::ToolCall(tool_call) => {
2465 assert_eq!(tool_call.title, expected_tools.remove(0));
2466 if tool_call.title == "Echo" {
2467 echo_id = Some(tool_call.tool_call_id);
2468 }
2469 }
2470 ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2471 acp::ToolCallUpdate {
2472 tool_call_id,
2473 fields:
2474 acp::ToolCallUpdateFields {
2475 status: Some(acp::ToolCallStatus::Completed),
2476 ..
2477 },
2478 ..
2479 },
2480 )) if Some(&tool_call_id) == echo_id.as_ref() => {
2481 echo_completed = true;
2482 }
2483 _ => {}
2484 }
2485
2486 if expected_tools.is_empty() && echo_completed {
2487 break;
2488 }
2489 }
2490
2491 // Cancel the current send and ensure that the event stream is closed, even
2492 // if one of the tools is still running.
2493 thread.update(cx, |thread, cx| thread.cancel(cx)).await;
2494 let events = events.collect::<Vec<_>>().await;
2495 let last_event = events.last();
2496 assert!(
2497 matches!(
2498 last_event,
2499 Some(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled)))
2500 ),
2501 "unexpected event {last_event:?}"
2502 );
2503
2504 // Ensure we can still send a new message after cancellation.
2505 let events = thread
2506 .update(cx, |thread, cx| {
2507 thread.send(
2508 ClientUserMessageId::new(),
2509 ["Testing: reply with 'Hello' then stop."],
2510 cx,
2511 )
2512 })
2513 .unwrap()
2514 .collect::<Vec<_>>()
2515 .await;
2516 thread.update(cx, |thread, _cx| {
2517 let message = thread.last_received_or_pending_message().unwrap();
2518 let agent_message = message.as_agent_message().unwrap();
2519 assert_eq!(
2520 agent_message.content,
2521 vec![AgentMessageContent::Text("Hello".to_string())]
2522 );
2523 });
2524 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
2525}
2526
2527#[gpui::test]
2528async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext) {
2529 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
2530 always_allow_tools(cx);
2531 disable_sandboxing(cx);
2532 let fake_model = model.as_fake();
2533
2534 let environment = Rc::new(cx.update(|cx| {
2535 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
2536 }));
2537 let handle = environment.terminal_handle.clone().unwrap();
2538
2539 let mut events = thread
2540 .update(cx, |thread, cx| {
2541 thread.add_tool(crate::TerminalTool::new(
2542 thread.project().clone(),
2543 environment,
2544 ));
2545 thread.send(ClientUserMessageId::new(), ["run a command"], cx)
2546 })
2547 .unwrap();
2548
2549 cx.run_until_parked();
2550
2551 // Simulate the model calling the terminal tool
2552 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2553 LanguageModelToolUse {
2554 id: "terminal_tool_1".into(),
2555 name: TerminalTool::NAME.into(),
2556 raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
2557 input: language_model::LanguageModelToolUseInput::Json(
2558 json!({"command": "sleep 1000", "cd": "."}),
2559 ),
2560 is_input_complete: true,
2561 thought_signature: None,
2562 },
2563 ));
2564 fake_model.end_last_completion_stream();
2565
2566 // Wait for the terminal tool to start running
2567 wait_for_terminal_tool_started(&mut events, cx).await;
2568
2569 // Cancel the thread while the terminal is running
2570 thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
2571
2572 // Collect remaining events, driving the executor to let cancellation complete
2573 let remaining_events = collect_events_until_stop(&mut events, cx).await;
2574
2575 // Verify the terminal was killed
2576 assert!(
2577 handle.was_killed(),
2578 "expected terminal handle to be killed on cancellation"
2579 );
2580
2581 // Verify we got a cancellation stop event
2582 assert_eq!(
2583 stop_events(remaining_events),
2584 vec![acp::StopReason::Cancelled],
2585 );
2586
2587 // Verify the tool result contains the terminal output, not just "Tool canceled by user"
2588 thread.update(cx, |thread, _cx| {
2589 let message = thread.last_received_or_pending_message().unwrap();
2590 let agent_message = message.as_agent_message().unwrap();
2591
2592 let tool_use = agent_message
2593 .content
2594 .iter()
2595 .find_map(|content| match content {
2596 AgentMessageContent::ToolUse(tool_use) => Some(tool_use),
2597 _ => None,
2598 })
2599 .expect("expected tool use in agent message");
2600
2601 let tool_result = agent_message
2602 .tool_results
2603 .get(&tool_use.id)
2604 .expect("expected tool result");
2605
2606 let result_text = tool_result.text_contents();
2607
2608 // "partial output" comes from FakeTerminalHandle's output field
2609 assert!(
2610 result_text.contains("partial output"),
2611 "expected tool result to contain terminal output, got: {result_text}"
2612 );
2613 // Match the actual format from process_content in terminal_tool.rs
2614 assert!(
2615 result_text.contains("The user stopped this command"),
2616 "expected tool result to indicate user stopped, got: {result_text}"
2617 );
2618 });
2619
2620 // Verify we can send a new message after cancellation
2621 verify_thread_recovery(&thread, &fake_model, cx).await;
2622}
2623
2624#[gpui::test]
2625async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppContext) {
2626 // This test verifies that tools which properly handle cancellation via
2627 // `event_stream.cancelled_by_user()` (like edit_file_tool) respond promptly
2628 // to cancellation and report that they were cancelled.
2629 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
2630 always_allow_tools(cx);
2631 let fake_model = model.as_fake();
2632
2633 let (tool, was_cancelled) = CancellationAwareTool::new();
2634
2635 let mut events = thread
2636 .update(cx, |thread, cx| {
2637 thread.add_tool(tool);
2638 thread.send(
2639 ClientUserMessageId::new(),
2640 ["call the cancellation aware tool"],
2641 cx,
2642 )
2643 })
2644 .unwrap();
2645
2646 cx.run_until_parked();
2647
2648 // Simulate the model calling the cancellation-aware tool
2649 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2650 LanguageModelToolUse {
2651 id: "cancellation_aware_1".into(),
2652 name: "cancellation_aware".into(),
2653 raw_input: r#"{}"#.into(),
2654 input: language_model::LanguageModelToolUseInput::Json(json!({})),
2655 is_input_complete: true,
2656 thought_signature: None,
2657 },
2658 ));
2659 fake_model.end_last_completion_stream();
2660
2661 cx.run_until_parked();
2662
2663 // Wait for the tool call to be reported
2664 let mut tool_started = false;
2665 let deadline = cx.executor().num_cpus() * 100;
2666 for _ in 0..deadline {
2667 cx.run_until_parked();
2668
2669 while let Some(Some(event)) = events.next().now_or_never() {
2670 if let Ok(ThreadEvent::ToolCall(tool_call)) = &event {
2671 if tool_call.title == "Cancellation Aware Tool" {
2672 tool_started = true;
2673 break;
2674 }
2675 }
2676 }
2677
2678 if tool_started {
2679 break;
2680 }
2681
2682 cx.background_executor
2683 .timer(Duration::from_millis(10))
2684 .await;
2685 }
2686 assert!(tool_started, "expected cancellation aware tool to start");
2687
2688 // Cancel the thread and wait for it to complete
2689 let cancel_task = thread.update(cx, |thread, cx| thread.cancel(cx));
2690
2691 // The cancel task should complete promptly because the tool handles cancellation
2692 let timeout = cx.background_executor.timer(Duration::from_secs(5));
2693 futures::select! {
2694 _ = cancel_task.fuse() => {}
2695 _ = timeout.fuse() => {
2696 panic!("cancel task timed out - tool did not respond to cancellation");
2697 }
2698 }
2699
2700 // Verify the tool detected cancellation via its flag
2701 assert!(
2702 was_cancelled.load(std::sync::atomic::Ordering::SeqCst),
2703 "tool should have detected cancellation via event_stream.cancelled_by_user()"
2704 );
2705
2706 // Collect remaining events
2707 let remaining_events = collect_events_until_stop(&mut events, cx).await;
2708
2709 // Verify we got a cancellation stop event
2710 assert_eq!(
2711 stop_events(remaining_events),
2712 vec![acp::StopReason::Cancelled],
2713 );
2714
2715 // Verify we can send a new message after cancellation
2716 verify_thread_recovery(&thread, &fake_model, cx).await;
2717}
2718
2719/// Helper to verify thread can recover after cancellation by sending a simple message.
2720async fn verify_thread_recovery(
2721 thread: &Entity<Thread>,
2722 fake_model: &FakeLanguageModel,
2723 cx: &mut TestAppContext,
2724) {
2725 let events = thread
2726 .update(cx, |thread, cx| {
2727 thread.send(
2728 ClientUserMessageId::new(),
2729 ["Testing: reply with 'Hello' then stop."],
2730 cx,
2731 )
2732 })
2733 .unwrap();
2734 cx.run_until_parked();
2735 fake_model.send_last_completion_stream_text_chunk("Hello");
2736 fake_model
2737 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
2738 fake_model.end_last_completion_stream();
2739
2740 let events = events.collect::<Vec<_>>().await;
2741 thread.update(cx, |thread, _cx| {
2742 let message = thread.last_received_or_pending_message().unwrap();
2743 let agent_message = message.as_agent_message().unwrap();
2744 assert_eq!(
2745 agent_message.content,
2746 vec![AgentMessageContent::Text("Hello".to_string())]
2747 );
2748 });
2749 assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
2750}
2751
2752/// Waits for a terminal tool to start by watching for a ToolCallUpdate with terminal content.
2753async fn wait_for_terminal_tool_started(
2754 events: &mut mpsc::UnboundedReceiver<Result<ThreadEvent>>,
2755 cx: &mut TestAppContext,
2756) {
2757 let deadline = cx.executor().num_cpus() * 100; // Scale with available parallelism
2758 for _ in 0..deadline {
2759 cx.run_until_parked();
2760
2761 while let Some(Some(event)) = events.next().now_or_never() {
2762 if let Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2763 update,
2764 ))) = &event
2765 {
2766 if update.fields.content.as_ref().is_some_and(|content| {
2767 content
2768 .iter()
2769 .any(|c| matches!(c, acp::ToolCallContent::Terminal(_)))
2770 }) {
2771 return;
2772 }
2773 }
2774 }
2775
2776 cx.background_executor
2777 .timer(Duration::from_millis(10))
2778 .await;
2779 }
2780 panic!("terminal tool did not start within the expected time");
2781}
2782
2783/// Collects events until a Stop event is received, driving the executor to completion.
2784async fn collect_events_until_stop(
2785 events: &mut mpsc::UnboundedReceiver<Result<ThreadEvent>>,
2786 cx: &mut TestAppContext,
2787) -> Vec<Result<ThreadEvent>> {
2788 let mut collected = Vec::new();
2789 let deadline = cx.executor().num_cpus() * 200;
2790
2791 for _ in 0..deadline {
2792 cx.executor().advance_clock(Duration::from_millis(10));
2793 cx.run_until_parked();
2794
2795 while let Some(Some(event)) = events.next().now_or_never() {
2796 let is_stop = matches!(&event, Ok(ThreadEvent::Stop(_)));
2797 collected.push(event);
2798 if is_stop {
2799 return collected;
2800 }
2801 }
2802 }
2803 panic!(
2804 "did not receive Stop event within the expected time; collected {} events",
2805 collected.len()
2806 );
2807}
2808
2809#[gpui::test]
2810async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) {
2811 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
2812 always_allow_tools(cx);
2813 disable_sandboxing(cx);
2814 let fake_model = model.as_fake();
2815
2816 let environment = Rc::new(cx.update(|cx| {
2817 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
2818 }));
2819 let handle = environment.terminal_handle.clone().unwrap();
2820
2821 let message_id = ClientUserMessageId::new();
2822 let mut events = thread
2823 .update(cx, |thread, cx| {
2824 thread.add_tool(crate::TerminalTool::new(
2825 thread.project().clone(),
2826 environment,
2827 ));
2828 thread.send(message_id.clone(), ["run a command"], cx)
2829 })
2830 .unwrap();
2831
2832 cx.run_until_parked();
2833
2834 // Simulate the model calling the terminal tool
2835 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2836 LanguageModelToolUse {
2837 id: "terminal_tool_1".into(),
2838 name: TerminalTool::NAME.into(),
2839 raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
2840 input: language_model::LanguageModelToolUseInput::Json(
2841 json!({"command": "sleep 1000", "cd": "."}),
2842 ),
2843 is_input_complete: true,
2844 thought_signature: None,
2845 },
2846 ));
2847 fake_model.end_last_completion_stream();
2848
2849 // Wait for the terminal tool to start running
2850 wait_for_terminal_tool_started(&mut events, cx).await;
2851
2852 // Truncate the thread while the terminal is running
2853 thread
2854 .update(cx, |thread, cx| thread.truncate(message_id, cx))
2855 .unwrap();
2856
2857 // Drive the executor to let cancellation complete
2858 let _ = collect_events_until_stop(&mut events, cx).await;
2859
2860 // Verify the terminal was killed
2861 assert!(
2862 handle.was_killed(),
2863 "expected terminal handle to be killed on truncate"
2864 );
2865
2866 // Verify the thread is empty after truncation
2867 thread.update(cx, |thread, _cx| {
2868 assert_eq!(
2869 thread.to_markdown(),
2870 "",
2871 "expected thread to be empty after truncating the only message"
2872 );
2873 });
2874
2875 // Verify we can send a new message after truncation
2876 verify_thread_recovery(&thread, &fake_model, cx).await;
2877}
2878
2879#[gpui::test]
2880async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext) {
2881 // Tests that cancellation properly kills all running terminal tools when multiple are active.
2882 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
2883 always_allow_tools(cx);
2884 disable_sandboxing(cx);
2885 let fake_model = model.as_fake();
2886
2887 let environment = Rc::new(MultiTerminalEnvironment::new());
2888
2889 let mut events = thread
2890 .update(cx, |thread, cx| {
2891 thread.add_tool(crate::TerminalTool::new(
2892 thread.project().clone(),
2893 environment.clone(),
2894 ));
2895 thread.send(ClientUserMessageId::new(), ["run multiple commands"], cx)
2896 })
2897 .unwrap();
2898
2899 cx.run_until_parked();
2900
2901 // Simulate the model calling two terminal tools
2902 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2903 LanguageModelToolUse {
2904 id: "terminal_tool_1".into(),
2905 name: TerminalTool::NAME.into(),
2906 raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
2907 input: language_model::LanguageModelToolUseInput::Json(
2908 json!({"command": "sleep 1000", "cd": "."}),
2909 ),
2910 is_input_complete: true,
2911 thought_signature: None,
2912 },
2913 ));
2914 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
2915 LanguageModelToolUse {
2916 id: "terminal_tool_2".into(),
2917 name: TerminalTool::NAME.into(),
2918 raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(),
2919 input: language_model::LanguageModelToolUseInput::Json(
2920 json!({"command": "sleep 2000", "cd": "."}),
2921 ),
2922 is_input_complete: true,
2923 thought_signature: None,
2924 },
2925 ));
2926 fake_model.end_last_completion_stream();
2927
2928 // Wait for both terminal tools to start by counting terminal content updates
2929 let mut terminals_started = 0;
2930 let deadline = cx.executor().num_cpus() * 100;
2931 for _ in 0..deadline {
2932 cx.run_until_parked();
2933
2934 while let Some(Some(event)) = events.next().now_or_never() {
2935 if let Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
2936 update,
2937 ))) = &event
2938 {
2939 if update.fields.content.as_ref().is_some_and(|content| {
2940 content
2941 .iter()
2942 .any(|c| matches!(c, acp::ToolCallContent::Terminal(_)))
2943 }) {
2944 terminals_started += 1;
2945 if terminals_started >= 2 {
2946 break;
2947 }
2948 }
2949 }
2950 }
2951 if terminals_started >= 2 {
2952 break;
2953 }
2954
2955 cx.background_executor
2956 .timer(Duration::from_millis(10))
2957 .await;
2958 }
2959 assert!(
2960 terminals_started >= 2,
2961 "expected 2 terminal tools to start, got {terminals_started}"
2962 );
2963
2964 // Cancel the thread while both terminals are running
2965 thread.update(cx, |thread, cx| thread.cancel(cx)).detach();
2966
2967 // Collect remaining events
2968 let remaining_events = collect_events_until_stop(&mut events, cx).await;
2969
2970 // Verify both terminal handles were killed
2971 let handles = environment.handles();
2972 assert_eq!(
2973 handles.len(),
2974 2,
2975 "expected 2 terminal handles to be created"
2976 );
2977 assert!(
2978 handles[0].was_killed(),
2979 "expected first terminal handle to be killed on cancellation"
2980 );
2981 assert!(
2982 handles[1].was_killed(),
2983 "expected second terminal handle to be killed on cancellation"
2984 );
2985
2986 // Verify we got a cancellation stop event
2987 assert_eq!(
2988 stop_events(remaining_events),
2989 vec![acp::StopReason::Cancelled],
2990 );
2991}
2992
2993#[gpui::test]
2994async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppContext) {
2995 // Tests that clicking the stop button on the terminal card (as opposed to the main
2996 // cancel button) properly reports user stopped via the was_stopped_by_user path.
2997 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
2998 always_allow_tools(cx);
2999 disable_sandboxing(cx);
3000 let fake_model = model.as_fake();
3001
3002 let environment = Rc::new(cx.update(|cx| {
3003 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
3004 }));
3005 let handle = environment.terminal_handle.clone().unwrap();
3006
3007 let mut events = thread
3008 .update(cx, |thread, cx| {
3009 thread.add_tool(crate::TerminalTool::new(
3010 thread.project().clone(),
3011 environment,
3012 ));
3013 thread.send(ClientUserMessageId::new(), ["run a command"], cx)
3014 })
3015 .unwrap();
3016
3017 cx.run_until_parked();
3018
3019 // Simulate the model calling the terminal tool
3020 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
3021 LanguageModelToolUse {
3022 id: "terminal_tool_1".into(),
3023 name: TerminalTool::NAME.into(),
3024 raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
3025 input: language_model::LanguageModelToolUseInput::Json(
3026 json!({"command": "sleep 1000", "cd": "."}),
3027 ),
3028 is_input_complete: true,
3029 thought_signature: None,
3030 },
3031 ));
3032 fake_model.end_last_completion_stream();
3033
3034 // Wait for the terminal tool to start running
3035 wait_for_terminal_tool_started(&mut events, cx).await;
3036
3037 // Simulate user clicking stop on the terminal card itself.
3038 // This sets the flag and signals exit (simulating what the real UI would do).
3039 handle.set_stopped_by_user(true);
3040 handle.killed.store(true, Ordering::SeqCst);
3041 handle.signal_exit();
3042
3043 // Wait for the tool to complete
3044 cx.run_until_parked();
3045
3046 // The thread continues after tool completion - simulate the model ending its turn
3047 fake_model
3048 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3049 fake_model.end_last_completion_stream();
3050
3051 // Collect remaining events
3052 let remaining_events = collect_events_until_stop(&mut events, cx).await;
3053
3054 // Verify we got an EndTurn (not Cancelled, since we didn't cancel the thread)
3055 assert_eq!(
3056 stop_events(remaining_events),
3057 vec![acp::StopReason::EndTurn],
3058 );
3059
3060 // Verify the tool result indicates user stopped
3061 thread.update(cx, |thread, _cx| {
3062 let message = thread.last_received_or_pending_message().unwrap();
3063 let agent_message = message.as_agent_message().unwrap();
3064
3065 let tool_use = agent_message
3066 .content
3067 .iter()
3068 .find_map(|content| match content {
3069 AgentMessageContent::ToolUse(tool_use) => Some(tool_use),
3070 _ => None,
3071 })
3072 .expect("expected tool use in agent message");
3073
3074 let tool_result = agent_message
3075 .tool_results
3076 .get(&tool_use.id)
3077 .expect("expected tool result");
3078
3079 let result_text = tool_result.text_contents();
3080
3081 assert!(
3082 result_text.contains("The user stopped this command"),
3083 "expected tool result to indicate user stopped, got: {result_text}"
3084 );
3085 });
3086}
3087
3088#[gpui::test]
3089async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
3090 // Tests that when a timeout is configured and expires, the tool result indicates timeout.
3091 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3092 always_allow_tools(cx);
3093 disable_sandboxing(cx);
3094 let fake_model = model.as_fake();
3095
3096 let environment = Rc::new(cx.update(|cx| {
3097 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
3098 }));
3099 let handle = environment.terminal_handle.clone().unwrap();
3100
3101 let mut events = thread
3102 .update(cx, |thread, cx| {
3103 thread.add_tool(crate::TerminalTool::new(
3104 thread.project().clone(),
3105 environment,
3106 ));
3107 thread.send(
3108 ClientUserMessageId::new(),
3109 ["run a command with timeout"],
3110 cx,
3111 )
3112 })
3113 .unwrap();
3114
3115 cx.run_until_parked();
3116
3117 // Simulate the model calling the terminal tool with a short timeout
3118 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
3119 LanguageModelToolUse {
3120 id: "terminal_tool_1".into(),
3121 name: TerminalTool::NAME.into(),
3122 raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(),
3123 input: language_model::LanguageModelToolUseInput::Json(
3124 json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
3125 ),
3126 is_input_complete: true,
3127 thought_signature: None,
3128 },
3129 ));
3130 fake_model.end_last_completion_stream();
3131
3132 // Wait for the terminal tool to start running
3133 wait_for_terminal_tool_started(&mut events, cx).await;
3134
3135 // Advance clock past the timeout
3136 cx.executor().advance_clock(Duration::from_millis(200));
3137 cx.run_until_parked();
3138
3139 // The thread continues after tool completion - simulate the model ending its turn
3140 fake_model
3141 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3142 fake_model.end_last_completion_stream();
3143
3144 // Collect remaining events
3145 let remaining_events = collect_events_until_stop(&mut events, cx).await;
3146
3147 // Verify the terminal was killed due to timeout
3148 assert!(
3149 handle.was_killed(),
3150 "expected terminal handle to be killed on timeout"
3151 );
3152
3153 // Verify we got an EndTurn (the tool completed, just with timeout)
3154 assert_eq!(
3155 stop_events(remaining_events),
3156 vec![acp::StopReason::EndTurn],
3157 );
3158
3159 // Verify the tool result indicates timeout, not user stopped
3160 thread.update(cx, |thread, _cx| {
3161 let message = thread.last_received_or_pending_message().unwrap();
3162 let agent_message = message.as_agent_message().unwrap();
3163
3164 let tool_use = agent_message
3165 .content
3166 .iter()
3167 .find_map(|content| match content {
3168 AgentMessageContent::ToolUse(tool_use) => Some(tool_use),
3169 _ => None,
3170 })
3171 .expect("expected tool use in agent message");
3172
3173 let tool_result = agent_message
3174 .tool_results
3175 .get(&tool_use.id)
3176 .expect("expected tool result");
3177
3178 let result_text = tool_result.text_contents();
3179
3180 assert!(
3181 result_text.contains("timed out"),
3182 "expected tool result to indicate timeout, got: {result_text}"
3183 );
3184 assert!(
3185 !result_text.contains("The user stopped"),
3186 "tool result should not mention user stopped when it timed out, got: {result_text}"
3187 );
3188 });
3189}
3190
3191#[gpui::test]
3192async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
3193 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3194 let fake_model = model.as_fake();
3195
3196 let events_1 = thread
3197 .update(cx, |thread, cx| {
3198 thread.send(ClientUserMessageId::new(), ["Hello 1"], cx)
3199 })
3200 .unwrap();
3201 cx.run_until_parked();
3202 fake_model.send_last_completion_stream_text_chunk("Hey 1!");
3203 cx.run_until_parked();
3204
3205 let events_2 = thread
3206 .update(cx, |thread, cx| {
3207 thread.send(ClientUserMessageId::new(), ["Hello 2"], cx)
3208 })
3209 .unwrap();
3210 cx.run_until_parked();
3211 fake_model.send_last_completion_stream_text_chunk("Hey 2!");
3212 fake_model
3213 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3214 fake_model.end_last_completion_stream();
3215
3216 let events_1 = events_1.collect::<Vec<_>>().await;
3217 assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]);
3218 let events_2 = events_2.collect::<Vec<_>>().await;
3219 assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]);
3220}
3221
3222#[gpui::test]
3223async fn test_retry_cancelled_promptly_on_new_send(cx: &mut TestAppContext) {
3224 // Regression test: when a completion fails with a retryable error (e.g. upstream 500),
3225 // the retry loop waits on a timer. If the user switches models and sends a new message
3226 // during that delay, the old turn should exit immediately instead of retrying with the
3227 // stale model.
3228 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3229 let model_a = model.as_fake();
3230
3231 // Start a turn with model_a.
3232 let events_1 = thread
3233 .update(cx, |thread, cx| {
3234 thread.send(ClientUserMessageId::new(), ["Hello"], cx)
3235 })
3236 .unwrap();
3237 cx.run_until_parked();
3238 assert_eq!(model_a.completion_count(), 1);
3239
3240 // Model returns a retryable upstream 500. The turn enters the retry delay.
3241 model_a.send_last_completion_stream_error(
3242 LanguageModelCompletionError::UpstreamProviderError {
3243 message: "Internal server error".to_string(),
3244 status: http_client::StatusCode::INTERNAL_SERVER_ERROR,
3245 retry_after: None,
3246 },
3247 );
3248 model_a.end_last_completion_stream();
3249 cx.run_until_parked();
3250
3251 // The old completion was consumed; model_a has no pending requests yet because the
3252 // retry timer hasn't fired.
3253 assert_eq!(model_a.completion_count(), 0);
3254
3255 // Switch to model_b and send a new message. This cancels the old turn.
3256 let model_b = Arc::new(FakeLanguageModel::with_id_and_thinking(
3257 "fake", "model-b", "Model B", false,
3258 ));
3259 thread.update(cx, |thread, cx| {
3260 thread.set_model(model_b.clone(), cx);
3261 });
3262 let events_2 = thread
3263 .update(cx, |thread, cx| {
3264 thread.send(ClientUserMessageId::new(), ["Continue"], cx)
3265 })
3266 .unwrap();
3267 cx.run_until_parked();
3268
3269 // model_b should have received its completion request.
3270 assert_eq!(model_b.as_fake().completion_count(), 1);
3271
3272 // Advance the clock well past the retry delay (BASE_RETRY_DELAY = 5s).
3273 cx.executor().advance_clock(Duration::from_secs(10));
3274 cx.run_until_parked();
3275
3276 // model_a must NOT have received another completion request — the cancelled turn
3277 // should have exited during the retry delay rather than retrying with the old model.
3278 assert_eq!(
3279 model_a.completion_count(),
3280 0,
3281 "old model should not receive a retry request after cancellation"
3282 );
3283
3284 // Complete model_b's turn.
3285 model_b
3286 .as_fake()
3287 .send_last_completion_stream_text_chunk("Done!");
3288 model_b
3289 .as_fake()
3290 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3291 model_b.as_fake().end_last_completion_stream();
3292
3293 let events_1 = events_1.collect::<Vec<_>>().await;
3294 assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]);
3295
3296 let events_2 = events_2.collect::<Vec<_>>().await;
3297 assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]);
3298}
3299
3300#[gpui::test]
3301async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) {
3302 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3303 let fake_model = model.as_fake();
3304
3305 let events_1 = thread
3306 .update(cx, |thread, cx| {
3307 thread.send(ClientUserMessageId::new(), ["Hello 1"], cx)
3308 })
3309 .unwrap();
3310 cx.run_until_parked();
3311 fake_model.send_last_completion_stream_text_chunk("Hey 1!");
3312 fake_model
3313 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3314 fake_model.end_last_completion_stream();
3315 let events_1 = events_1.collect::<Vec<_>>().await;
3316
3317 let events_2 = thread
3318 .update(cx, |thread, cx| {
3319 thread.send(ClientUserMessageId::new(), ["Hello 2"], cx)
3320 })
3321 .unwrap();
3322 cx.run_until_parked();
3323 fake_model.send_last_completion_stream_text_chunk("Hey 2!");
3324 fake_model
3325 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
3326 fake_model.end_last_completion_stream();
3327 let events_2 = events_2.collect::<Vec<_>>().await;
3328
3329 assert_eq!(stop_events(events_1), vec![acp::StopReason::EndTurn]);
3330 assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]);
3331}
3332
3333#[gpui::test]
3334async fn test_refusal(cx: &mut TestAppContext) {
3335 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3336 let fake_model = model.as_fake();
3337
3338 let events = thread
3339 .update(cx, |thread, cx| {
3340 thread.send(ClientUserMessageId::new(), ["Hello"], cx)
3341 })
3342 .unwrap();
3343 cx.run_until_parked();
3344 thread.read_with(cx, |thread, _| {
3345 assert_eq!(
3346 thread.to_markdown(),
3347 indoc! {"
3348 ## User
3349
3350 Hello
3351 "}
3352 );
3353 });
3354
3355 fake_model.send_last_completion_stream_text_chunk("Hey!");
3356 cx.run_until_parked();
3357 thread.read_with(cx, |thread, _| {
3358 assert_eq!(
3359 thread.to_markdown(),
3360 indoc! {"
3361 ## User
3362
3363 Hello
3364
3365 ## Assistant
3366
3367 Hey!
3368 "}
3369 );
3370 });
3371
3372 // If the model refuses to continue, the thread should remove all the messages after the last user message.
3373 fake_model
3374 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::Refusal));
3375 let events = events.collect::<Vec<_>>().await;
3376 assert_eq!(stop_events(events), vec![acp::StopReason::Refusal]);
3377 thread.read_with(cx, |thread, _| {
3378 assert_eq!(thread.to_markdown(), "");
3379 });
3380}
3381
3382#[gpui::test]
3383async fn test_truncate_first_message(cx: &mut TestAppContext) {
3384 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3385 let fake_model = model.as_fake();
3386
3387 let message_id = ClientUserMessageId::new();
3388 thread
3389 .update(cx, |thread, cx| {
3390 thread.send(message_id.clone(), ["Hello"], cx)
3391 })
3392 .unwrap();
3393 cx.run_until_parked();
3394 thread.read_with(cx, |thread, _| {
3395 assert_eq!(
3396 thread.to_markdown(),
3397 indoc! {"
3398 ## User
3399
3400 Hello
3401 "}
3402 );
3403 assert_eq!(thread.latest_token_usage(), None);
3404 });
3405
3406 fake_model.send_last_completion_stream_text_chunk("Hey!");
3407 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3408 language_model::TokenUsage {
3409 input_tokens: 32_000,
3410 output_tokens: 16_000,
3411 cache_creation_input_tokens: 0,
3412 cache_read_input_tokens: 0,
3413 },
3414 ));
3415 cx.run_until_parked();
3416 thread.read_with(cx, |thread, _| {
3417 assert_eq!(
3418 thread.to_markdown(),
3419 indoc! {"
3420 ## User
3421
3422 Hello
3423
3424 ## Assistant
3425
3426 Hey!
3427 "}
3428 );
3429 assert_eq!(
3430 thread.latest_token_usage(),
3431 Some(acp_thread::TokenUsage {
3432 used_tokens: 32_000 + 16_000,
3433 max_tokens: 1_000_000,
3434 max_output_tokens: None,
3435 input_tokens: 32_000,
3436 output_tokens: 16_000,
3437 })
3438 );
3439 });
3440
3441 thread
3442 .update(cx, |thread, cx| thread.truncate(message_id, cx))
3443 .unwrap();
3444 cx.run_until_parked();
3445 thread.read_with(cx, |thread, _| {
3446 assert_eq!(thread.to_markdown(), "");
3447 assert_eq!(thread.latest_token_usage(), None);
3448 });
3449
3450 // Ensure we can still send a new message after truncation.
3451 thread
3452 .update(cx, |thread, cx| {
3453 thread.send(ClientUserMessageId::new(), ["Hi"], cx)
3454 })
3455 .unwrap();
3456 thread.update(cx, |thread, _cx| {
3457 assert_eq!(
3458 thread.to_markdown(),
3459 indoc! {"
3460 ## User
3461
3462 Hi
3463 "}
3464 );
3465 });
3466 cx.run_until_parked();
3467 fake_model.send_last_completion_stream_text_chunk("Ahoy!");
3468 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3469 language_model::TokenUsage {
3470 input_tokens: 40_000,
3471 output_tokens: 20_000,
3472 cache_creation_input_tokens: 0,
3473 cache_read_input_tokens: 0,
3474 },
3475 ));
3476 cx.run_until_parked();
3477 thread.read_with(cx, |thread, _| {
3478 assert_eq!(
3479 thread.to_markdown(),
3480 indoc! {"
3481 ## User
3482
3483 Hi
3484
3485 ## Assistant
3486
3487 Ahoy!
3488 "}
3489 );
3490
3491 assert_eq!(
3492 thread.latest_token_usage(),
3493 Some(acp_thread::TokenUsage {
3494 used_tokens: 40_000 + 20_000,
3495 max_tokens: 1_000_000,
3496 max_output_tokens: None,
3497 input_tokens: 40_000,
3498 output_tokens: 20_000,
3499 })
3500 );
3501 });
3502}
3503
3504#[gpui::test]
3505async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppContext) {
3506 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3507 let fake_model = model.as_fake();
3508
3509 let message_1_id = ClientUserMessageId::new();
3510 thread
3511 .update(cx, |thread, cx| {
3512 thread.send(message_1_id, ["Message 1"], cx)
3513 })
3514 .unwrap();
3515 cx.run_until_parked();
3516
3517 fake_model.send_last_completion_stream_text_chunk("Response 1");
3518 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3519 language_model::TokenUsage {
3520 input_tokens: 100,
3521 output_tokens: 50,
3522 cache_creation_input_tokens: 25,
3523 cache_read_input_tokens: 75,
3524 },
3525 ));
3526 fake_model.end_last_completion_stream();
3527 cx.run_until_parked();
3528
3529 thread.read_with(cx, |thread, _| {
3530 assert_eq!(
3531 thread.latest_token_usage(),
3532 Some(acp_thread::TokenUsage {
3533 used_tokens: 250,
3534 max_tokens: 1_000_000,
3535 max_output_tokens: None,
3536 input_tokens: 200,
3537 output_tokens: 50,
3538 })
3539 );
3540 });
3541
3542 let message_2_id = ClientUserMessageId::new();
3543 thread
3544 .update(cx, |thread, cx| {
3545 thread.send(message_2_id.clone(), ["Message 2"], cx)
3546 })
3547 .unwrap();
3548 cx.run_until_parked();
3549
3550 thread.read_with(cx, |thread, _| {
3551 assert_eq!(thread.tokens_before_message(&message_2_id), Some(200));
3552 });
3553}
3554
3555#[gpui::test]
3556async fn test_prompt_too_large_marks_token_usage_exceeded(cx: &mut TestAppContext) {
3557 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3558 let fake_model = model.as_fake();
3559
3560 thread
3561 .update(cx, |thread, cx| {
3562 thread.send(ClientUserMessageId::new(), ["Message 1"], cx)
3563 })
3564 .unwrap();
3565 cx.run_until_parked();
3566
3567 fake_model.send_last_completion_stream_text_chunk("Response 1");
3568 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3569 language_model::TokenUsage {
3570 input_tokens: 100,
3571 output_tokens: 50,
3572 ..Default::default()
3573 },
3574 ));
3575 fake_model.end_last_completion_stream();
3576 cx.run_until_parked();
3577
3578 thread.read_with(cx, |thread, _| {
3579 assert_eq!(
3580 thread.latest_token_usage().unwrap().ratio(),
3581 acp_thread::TokenUsageRatio::Normal
3582 );
3583 });
3584
3585 thread
3586 .update(cx, |thread, cx| {
3587 thread.send(ClientUserMessageId::new(), ["Message 2"], cx)
3588 })
3589 .unwrap();
3590 cx.run_until_parked();
3591
3592 fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge {
3593 tokens: None,
3594 });
3595 fake_model.end_last_completion_stream();
3596 cx.run_until_parked();
3597
3598 thread.read_with(cx, |thread, _| {
3599 let usage = thread.latest_token_usage().unwrap();
3600 assert_eq!(usage.used_tokens, 1_000_000);
3601 assert_eq!(usage.max_tokens, 1_000_000);
3602 assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded);
3603 });
3604}
3605
3606#[gpui::test]
3607async fn test_prompt_too_large_uses_reported_token_count(cx: &mut TestAppContext) {
3608 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3609 let fake_model = model.as_fake();
3610
3611 thread
3612 .update(cx, |thread, cx| {
3613 thread.send(ClientUserMessageId::new(), ["Message 1"], cx)
3614 })
3615 .unwrap();
3616 cx.run_until_parked();
3617
3618 fake_model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge {
3619 tokens: Some(1_500_000),
3620 });
3621 fake_model.end_last_completion_stream();
3622 cx.run_until_parked();
3623
3624 thread.read_with(cx, |thread, _| {
3625 let usage = thread.latest_token_usage().unwrap();
3626 assert_eq!(usage.used_tokens, 1_500_000);
3627 assert_eq!(usage.ratio(), acp_thread::TokenUsageRatio::Exceeded);
3628 });
3629}
3630
3631#[gpui::test]
3632async fn test_cumulative_token_usage(cx: &mut TestAppContext) {
3633 let ThreadTest {
3634 model,
3635 thread,
3636 project_context,
3637 ..
3638 } = setup(cx, TestModel::Fake).await;
3639 let fake_model = model.as_fake();
3640
3641 thread
3642 .update(cx, |thread, cx| {
3643 thread.add_tool(EchoTool);
3644 thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx)
3645 })
3646 .unwrap();
3647 cx.run_until_parked();
3648
3649 // The first request emits two cumulative snapshots; only the final values
3650 // must be counted, exactly once.
3651 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3652 TokenUsage {
3653 input_tokens: 100,
3654 output_tokens: 10,
3655 ..Default::default()
3656 },
3657 ));
3658 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3659 TokenUsage {
3660 input_tokens: 100,
3661 output_tokens: 50,
3662 ..Default::default()
3663 },
3664 ));
3665 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
3666 LanguageModelToolUse {
3667 id: "tool_1".into(),
3668 name: EchoTool::NAME.into(),
3669 raw_input: json!({"text": "hello"}).to_string(),
3670 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
3671 is_input_complete: true,
3672 thought_signature: None,
3673 },
3674 ));
3675 fake_model.end_last_completion_stream();
3676 cx.run_until_parked();
3677
3678 // The second request (after the tool call) is counted in addition to the first.
3679 fake_model.send_last_completion_stream_text_chunk("Done");
3680 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3681 TokenUsage {
3682 input_tokens: 200,
3683 output_tokens: 30,
3684 ..Default::default()
3685 },
3686 ));
3687 fake_model.end_last_completion_stream();
3688 cx.run_until_parked();
3689
3690 let expected = TokenUsage {
3691 input_tokens: 300,
3692 output_tokens: 80,
3693 ..Default::default()
3694 };
3695 thread.read_with(cx, |thread, _| {
3696 assert_eq!(thread.cumulative_token_usage(), expected);
3697 });
3698
3699 let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await;
3700 assert_eq!(db_thread.cumulative_token_usage, expected);
3701
3702 cx.update(|cx| {
3703 LanguageModelRegistry::test(cx);
3704 });
3705 let restored = cx.update(|cx| {
3706 let thread = thread.read(cx);
3707 let project = thread.project.clone();
3708 let context_server_registry = thread.context_server_registry.clone();
3709 let templates = thread.templates.clone();
3710 cx.new(|cx| {
3711 Thread::from_db(
3712 acp::SessionId::new("restored"),
3713 db_thread,
3714 project,
3715 project_context.clone(),
3716 context_server_registry,
3717 templates,
3718 cx,
3719 )
3720 })
3721 });
3722 restored.read_with(cx, |thread, _| {
3723 assert_eq!(thread.cumulative_token_usage(), expected);
3724 });
3725}
3726
3727#[gpui::test]
3728async fn test_cumulative_token_usage_keeps_accounted_usage_monotonic(cx: &mut TestAppContext) {
3729 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3730 let fake_model = model.as_fake();
3731
3732 thread
3733 .update(cx, |thread, cx| {
3734 thread.send(ClientUserMessageId::new(), ["hello"], cx)
3735 })
3736 .unwrap();
3737 cx.run_until_parked();
3738
3739 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3740 TokenUsage {
3741 input_tokens: 100,
3742 output_tokens: 10,
3743 ..Default::default()
3744 },
3745 ));
3746 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3747 TokenUsage::default(),
3748 ));
3749 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3750 TokenUsage {
3751 input_tokens: 100,
3752 output_tokens: 50,
3753 ..Default::default()
3754 },
3755 ));
3756 fake_model.end_last_completion_stream();
3757 cx.run_until_parked();
3758
3759 thread.read_with(cx, |thread, _| {
3760 assert_eq!(
3761 thread.cumulative_token_usage(),
3762 TokenUsage {
3763 input_tokens: 100,
3764 output_tokens: 50,
3765 ..Default::default()
3766 }
3767 );
3768 });
3769}
3770
3771#[gpui::test]
3772async fn test_truncate_second_message(cx: &mut TestAppContext) {
3773 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3774 let fake_model = model.as_fake();
3775
3776 thread
3777 .update(cx, |thread, cx| {
3778 thread.send(ClientUserMessageId::new(), ["Message 1"], cx)
3779 })
3780 .unwrap();
3781 cx.run_until_parked();
3782 fake_model.send_last_completion_stream_text_chunk("Message 1 response");
3783 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3784 language_model::TokenUsage {
3785 input_tokens: 32_000,
3786 output_tokens: 16_000,
3787 cache_creation_input_tokens: 0,
3788 cache_read_input_tokens: 0,
3789 },
3790 ));
3791 fake_model.end_last_completion_stream();
3792 cx.run_until_parked();
3793
3794 let assert_first_message_state = |cx: &mut TestAppContext| {
3795 thread.clone().read_with(cx, |thread, _| {
3796 assert_eq!(
3797 thread.to_markdown(),
3798 indoc! {"
3799 ## User
3800
3801 Message 1
3802
3803 ## Assistant
3804
3805 Message 1 response
3806 "}
3807 );
3808
3809 assert_eq!(
3810 thread.latest_token_usage(),
3811 Some(acp_thread::TokenUsage {
3812 used_tokens: 32_000 + 16_000,
3813 max_tokens: 1_000_000,
3814 max_output_tokens: None,
3815 input_tokens: 32_000,
3816 output_tokens: 16_000,
3817 })
3818 );
3819 });
3820 };
3821
3822 assert_first_message_state(cx);
3823
3824 let second_message_id = ClientUserMessageId::new();
3825 thread
3826 .update(cx, |thread, cx| {
3827 thread.send(second_message_id.clone(), ["Message 2"], cx)
3828 })
3829 .unwrap();
3830 cx.run_until_parked();
3831
3832 fake_model.send_last_completion_stream_text_chunk("Message 2 response");
3833 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
3834 language_model::TokenUsage {
3835 input_tokens: 40_000,
3836 output_tokens: 20_000,
3837 cache_creation_input_tokens: 0,
3838 cache_read_input_tokens: 0,
3839 },
3840 ));
3841 fake_model.end_last_completion_stream();
3842 cx.run_until_parked();
3843
3844 thread.read_with(cx, |thread, _| {
3845 assert_eq!(
3846 thread.to_markdown(),
3847 indoc! {"
3848 ## User
3849
3850 Message 1
3851
3852 ## Assistant
3853
3854 Message 1 response
3855
3856 ## User
3857
3858 Message 2
3859
3860 ## Assistant
3861
3862 Message 2 response
3863 "}
3864 );
3865
3866 assert_eq!(
3867 thread.latest_token_usage(),
3868 Some(acp_thread::TokenUsage {
3869 used_tokens: 40_000 + 20_000,
3870 max_tokens: 1_000_000,
3871 max_output_tokens: None,
3872 input_tokens: 40_000,
3873 output_tokens: 20_000,
3874 })
3875 );
3876 });
3877
3878 thread
3879 .update(cx, |thread, cx| thread.truncate(second_message_id, cx))
3880 .unwrap();
3881 cx.run_until_parked();
3882
3883 assert_first_message_state(cx);
3884}
3885
3886#[gpui::test]
3887async fn test_title_generation(cx: &mut TestAppContext) {
3888 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3889 let fake_model = model.as_fake();
3890
3891 let summary_model = Arc::new(FakeLanguageModel::default());
3892 thread.update(cx, |thread, cx| {
3893 thread.set_summarization_model(Some(summary_model.clone()), cx)
3894 });
3895
3896 let send = thread
3897 .update(cx, |thread, cx| {
3898 thread.send(ClientUserMessageId::new(), ["Hello"], cx)
3899 })
3900 .unwrap();
3901 cx.run_until_parked();
3902
3903 fake_model.send_last_completion_stream_text_chunk("Hey!");
3904 fake_model.end_last_completion_stream();
3905 cx.run_until_parked();
3906 thread.read_with(cx, |thread, _| assert_eq!(thread.title(), None));
3907
3908 // Ensure the summary model has been invoked to generate a title.
3909 summary_model.send_last_completion_stream_text_chunk("Hello ");
3910 summary_model.send_last_completion_stream_text_chunk("world\nG");
3911 summary_model.send_last_completion_stream_text_chunk("oodnight Moon");
3912 summary_model.end_last_completion_stream();
3913 send.collect::<Vec<_>>().await;
3914 cx.run_until_parked();
3915 thread.read_with(cx, |thread, _| {
3916 assert_eq!(thread.title(), Some("Hello world".into()))
3917 });
3918
3919 // Send another message, ensuring no title is generated this time.
3920 let send = thread
3921 .update(cx, |thread, cx| {
3922 thread.send(ClientUserMessageId::new(), ["Hello again"], cx)
3923 })
3924 .unwrap();
3925 cx.run_until_parked();
3926 fake_model.send_last_completion_stream_text_chunk("Hey again!");
3927 fake_model.end_last_completion_stream();
3928 cx.run_until_parked();
3929 assert_eq!(summary_model.pending_completions(), Vec::new());
3930 send.collect::<Vec<_>>().await;
3931 thread.read_with(cx, |thread, _| {
3932 assert_eq!(thread.title(), Some("Hello world".into()))
3933 });
3934}
3935
3936#[gpui::test]
3937async fn test_stream_thread_title_keeps_only_first_line(cx: &mut TestAppContext) {
3938 let model = Arc::new(FakeLanguageModel::default());
3939 let request = LanguageModelRequest::default();
3940
3941 let title_task = cx.spawn({
3942 let model = model.clone();
3943 async move |cx| crate::stream_thread_title(model, request, &cx).await
3944 });
3945
3946 cx.run_until_parked();
3947
3948 model.send_last_completion_stream_text_chunk("Hello world\nGoodnight Moon");
3949 model.end_last_completion_stream();
3950
3951 let title = title_task.await.unwrap();
3952 assert_eq!(title, "Hello world");
3953}
3954
3955#[gpui::test]
3956async fn test_stream_thread_title_stops_when_newline_ends_chunk(cx: &mut TestAppContext) {
3957 let model = Arc::new(FakeLanguageModel::default());
3958 let request = LanguageModelRequest::default();
3959
3960 let title_task = cx.spawn({
3961 let model = model.clone();
3962 async move |cx| crate::stream_thread_title(model, request, &cx).await
3963 });
3964
3965 cx.run_until_parked();
3966
3967 model.send_last_completion_stream_text_chunk("Hello world\n");
3968 model.send_last_completion_stream_text_chunk("Goodnight Moon");
3969 model.end_last_completion_stream();
3970
3971 let title = title_task.await.unwrap();
3972 assert_eq!(title, "Hello world");
3973}
3974
3975// `Thread::to_markdown` (live native) and `DbThread::to_markdown` (persisted
3976// native) must stay byte-for-byte identical for the same messages, since both
3977// back the sidebar's native "Open Thread as Markdown" action. This pins that
3978// they share a single rendering path.
3979#[gpui::test]
3980async fn test_db_thread_markdown_matches_live_thread(cx: &mut TestAppContext) {
3981 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
3982 let fake_model = model.as_fake();
3983
3984 let send = thread
3985 .update(cx, |thread, cx| {
3986 thread.send(ClientUserMessageId::new(), ["Hello"], cx)
3987 })
3988 .unwrap();
3989 cx.run_until_parked();
3990 fake_model.send_last_completion_stream_text_chunk("Hey there!");
3991 fake_model.end_last_completion_stream();
3992 send.collect::<Vec<_>>().await;
3993 cx.run_until_parked();
3994
3995 let db_thread = thread.update(cx, |thread, cx| thread.to_db(cx)).await;
3996 let live_markdown = thread.read_with(cx, |thread, _| thread.to_markdown());
3997
3998 assert!(!live_markdown.is_empty());
3999 assert_eq!(db_thread.to_markdown(), live_markdown);
4000}
4001
4002#[gpui::test]
4003async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) {
4004 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
4005 let fake_model = model.as_fake();
4006
4007 let summary_model = Arc::new(FakeLanguageModel::default());
4008 let fake_summary_model = summary_model.as_fake();
4009 thread.update(cx, |thread, cx| {
4010 thread.set_summarization_model(Some(summary_model.clone()), cx)
4011 });
4012
4013 let send = thread
4014 .update(cx, |thread, cx| {
4015 thread.send(ClientUserMessageId::new(), ["Hello"], cx)
4016 })
4017 .unwrap();
4018 cx.run_until_parked();
4019
4020 fake_model.send_last_completion_stream_text_chunk("Hey!");
4021 fake_model.end_last_completion_stream();
4022 cx.run_until_parked();
4023
4024 fake_summary_model.send_last_completion_stream_error(
4025 LanguageModelCompletionError::UpstreamProviderError {
4026 message: "Internal server error".to_string(),
4027 status: gpui::http_client::StatusCode::INTERNAL_SERVER_ERROR,
4028 retry_after: None,
4029 },
4030 );
4031 fake_summary_model.end_last_completion_stream();
4032 send.collect::<Vec<_>>().await;
4033 cx.run_until_parked();
4034
4035 thread.read_with(cx, |thread, _| {
4036 assert_eq!(thread.title(), None);
4037 assert!(thread.has_failed_title_generation());
4038 assert!(
4039 thread
4040 .title_generation_error()
4041 .is_some_and(|error| error.contains("Internal server error"))
4042 );
4043 assert!(!thread.is_generating_title());
4044 });
4045
4046 thread.update(cx, |thread, cx| {
4047 thread.generate_title(cx);
4048 });
4049 cx.run_until_parked();
4050
4051 thread.read_with(cx, |thread, _| {
4052 assert!(!thread.has_failed_title_generation());
4053 assert_eq!(thread.title_generation_error(), None);
4054 assert!(thread.is_generating_title());
4055 });
4056
4057 fake_summary_model.send_last_completion_stream_text_chunk("Retried title");
4058 fake_summary_model.end_last_completion_stream();
4059 cx.run_until_parked();
4060
4061 thread.read_with(cx, |thread, _| {
4062 assert_eq!(thread.title(), Some("Retried title".into()));
4063 assert!(!thread.has_failed_title_generation());
4064 assert_eq!(thread.title_generation_error(), None);
4065 assert!(!thread.is_generating_title());
4066 });
4067}
4068
4069#[gpui::test]
4070async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
4071 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
4072 let fake_model = model.as_fake();
4073
4074 let _events = thread
4075 .update(cx, |thread, cx| {
4076 thread.add_tool(ToolRequiringPermission);
4077 thread.add_tool(EchoTool);
4078 thread.send(ClientUserMessageId::new(), ["Hey!"], cx)
4079 })
4080 .unwrap();
4081 cx.run_until_parked();
4082
4083 let permission_tool_use = LanguageModelToolUse {
4084 id: "tool_id_1".into(),
4085 name: ToolRequiringPermission::NAME.into(),
4086 raw_input: "{}".into(),
4087 input: language_model::LanguageModelToolUseInput::Json(json!({})),
4088 is_input_complete: true,
4089 thought_signature: None,
4090 };
4091 let echo_tool_use = LanguageModelToolUse {
4092 id: "tool_id_2".into(),
4093 name: EchoTool::NAME.into(),
4094 raw_input: json!({"text": "test"}).to_string(),
4095 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
4096 is_input_complete: true,
4097 thought_signature: None,
4098 };
4099 fake_model.send_last_completion_stream_text_chunk("Hi!");
4100 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
4101 permission_tool_use,
4102 ));
4103 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
4104 echo_tool_use.clone(),
4105 ));
4106 fake_model.end_last_completion_stream();
4107 cx.run_until_parked();
4108
4109 // Ensure pending tools are skipped when building a request.
4110 let request = thread
4111 .read_with(cx, |thread, cx| {
4112 thread.build_completion_request(CompletionIntent::EditFile, cx)
4113 })
4114 .unwrap();
4115 assert_eq!(
4116 request.messages[1..],
4117 vec![
4118 LanguageModelRequestMessage {
4119 role: Role::User,
4120 content: vec!["Hey!".into()],
4121 cache: true,
4122 reasoning_details: None,
4123 },
4124 LanguageModelRequestMessage {
4125 role: Role::Assistant,
4126 content: vec![
4127 MessageContent::Text("Hi!".into()),
4128 MessageContent::ToolUse(echo_tool_use.clone())
4129 ],
4130 cache: false,
4131 reasoning_details: None,
4132 },
4133 LanguageModelRequestMessage {
4134 role: Role::User,
4135 content: vec![MessageContent::ToolResult(LanguageModelToolResult {
4136 tool_use_id: echo_tool_use.id.clone(),
4137 tool_name: echo_tool_use.name,
4138 is_error: false,
4139 content: vec!["test".into()],
4140 output: Some("test".into())
4141 })],
4142 cache: false,
4143 reasoning_details: None,
4144 },
4145 ],
4146 );
4147}
4148
4149#[gpui::test]
4150async fn test_agent_connection(cx: &mut TestAppContext) {
4151 cx.update(settings::init);
4152 let templates = Templates::new();
4153
4154 // Initialize language model system with test provider
4155 cx.update(|cx| {
4156 gpui_tokio::init(cx);
4157
4158 let http_client = FakeHttpClient::with_404_response();
4159 let clock = Arc::new(clock::FakeSystemClock::new());
4160 let client = Client::new(clock, http_client, cx);
4161 let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
4162 language_model::init(cx);
4163 RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
4164 language_models::init(user_store, client.clone(), cx);
4165 LanguageModelRegistry::test(cx);
4166 });
4167 cx.executor().forbid_parking();
4168
4169 // Create a project for new_thread
4170 let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone()));
4171 fake_fs.insert_tree(path!("/test"), json!({})).await;
4172 let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await;
4173 let cwd = PathList::new(&[Path::new("/test")]);
4174 let thread_store = cx.new(|cx| ThreadStore::new(cx));
4175
4176 // Create agent and connection
4177 let agent =
4178 cx.update(|cx| NativeAgent::new(thread_store, templates.clone(), fake_fs.clone(), cx));
4179 let connection = NativeAgentConnection(agent.clone());
4180
4181 // Create a thread using new_thread
4182 let connection_rc = Rc::new(connection.clone());
4183 let acp_thread = cx
4184 .update(|cx| connection_rc.new_session(project, cwd, cx))
4185 .await
4186 .expect("new_thread should succeed");
4187
4188 // Get the session_id from the AcpThread
4189 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
4190
4191 // Test model_selector returns Some
4192 let selector_opt = connection.model_selector(&session_id);
4193 assert!(
4194 selector_opt.is_some(),
4195 "agent should always support ModelSelector"
4196 );
4197 let selector = selector_opt.unwrap();
4198
4199 // Test list_models
4200 let listed_models = cx
4201 .update(|cx| selector.list_models(cx))
4202 .await
4203 .expect("list_models should succeed");
4204 let AgentModelList::Grouped(listed_models) = listed_models else {
4205 panic!("Unexpected model list type");
4206 };
4207 assert!(!listed_models.is_empty(), "should have at least one model");
4208 assert_eq!(
4209 listed_models[&AgentModelGroupName("Fake".into())][0]
4210 .id
4211 .as_ref(),
4212 "fake/fake"
4213 );
4214
4215 // Test selected_model returns the default
4216 let model = cx
4217 .update(|cx| selector.selected_model(cx))
4218 .await
4219 .expect("selected_model should succeed");
4220 let model = cx
4221 .update(|cx| agent.read(cx).models().model_from_id(&model.id))
4222 .unwrap();
4223 let model = model.as_fake();
4224 assert_eq!(model.id().0, "fake", "should return default model");
4225
4226 let request = acp_thread.update(cx, |thread, cx| thread.send(vec!["abc".into()], cx));
4227 cx.run_until_parked();
4228 model.send_last_completion_stream_text_chunk("def");
4229 cx.run_until_parked();
4230 acp_thread.read_with(cx, |thread, cx| {
4231 assert_eq!(
4232 thread.to_markdown(cx),
4233 indoc! {"
4234 ## User
4235
4236 abc
4237
4238 ## Assistant
4239
4240 def
4241
4242 "}
4243 )
4244 });
4245
4246 // Test cancel
4247 cx.update(|cx| connection.cancel(&session_id, cx));
4248 request.await.expect("prompt should fail gracefully");
4249
4250 // Explicitly close the session and drop the ACP thread.
4251 cx.update(|cx| Rc::new(connection.clone()).close_session(&session_id, cx))
4252 .await
4253 .unwrap();
4254 drop(acp_thread);
4255 let result = cx
4256 .update(|cx| {
4257 acp_thread::AgentSessionClientUserMessageIds::prompt(
4258 &connection,
4259 acp_thread::ClientUserMessageId::new(),
4260 acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]),
4261 cx,
4262 )
4263 })
4264 .await;
4265 assert_eq!(
4266 result.as_ref().unwrap_err().to_string(),
4267 "Session not found",
4268 "unexpected result: {:?}",
4269 result
4270 );
4271}
4272
4273#[gpui::test]
4274async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
4275 let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
4276 thread.update(cx, |thread, _cx| thread.add_tool(EchoTool));
4277 let fake_model = model.as_fake();
4278
4279 let mut events = thread
4280 .update(cx, |thread, cx| {
4281 thread.send(ClientUserMessageId::new(), ["Echo something"], cx)
4282 })
4283 .unwrap();
4284 cx.run_until_parked();
4285
4286 // Simulate streaming partial input.
4287 let input = json!({});
4288 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
4289 LanguageModelToolUse {
4290 id: "1".into(),
4291 name: EchoTool::NAME.into(),
4292 raw_input: input.to_string(),
4293 input: language_model::LanguageModelToolUseInput::Json(input),
4294 is_input_complete: false,
4295 thought_signature: None,
4296 },
4297 ));
4298
4299 // Input streaming completed
4300 let input = json!({ "text": "Hello!" });
4301 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
4302 LanguageModelToolUse {
4303 id: "1".into(),
4304 name: "echo".into(),
4305 raw_input: input.to_string(),
4306 input: language_model::LanguageModelToolUseInput::Json(input),
4307 is_input_complete: true,
4308 thought_signature: None,
4309 },
4310 ));
4311 fake_model.end_last_completion_stream();
4312 cx.run_until_parked();
4313
4314 // User message is index 0, so the tool call is scoped to index 1 (see
4315 // `scoped_tool_call_id`).
4316 let tool_call_id = scoped_tool_call_id(1, &"1".into());
4317
4318 let tool_call = expect_tool_call(&mut events).await;
4319 assert_eq!(
4320 tool_call,
4321 acp::ToolCall::new(tool_call_id.clone(), "Echo")
4322 .raw_input(json!({}))
4323 .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())]))
4324 );
4325 let update = expect_tool_call_update_fields(&mut events).await;
4326 assert_eq!(
4327 update,
4328 acp::ToolCallUpdate::new(
4329 tool_call_id.clone(),
4330 acp::ToolCallUpdateFields::new()
4331 .title("Echo")
4332 .kind(acp::ToolKind::Other)
4333 .raw_input(json!({ "text": "Hello!"}))
4334 )
4335 );
4336 let update = expect_tool_call_update_fields(&mut events).await;
4337 assert_eq!(
4338 update,
4339 acp::ToolCallUpdate::new(
4340 tool_call_id.clone(),
4341 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress)
4342 )
4343 );
4344 let update = expect_tool_call_update_fields(&mut events).await;
4345 assert_eq!(
4346 update,
4347 acp::ToolCallUpdate::new(
4348 tool_call_id,
4349 acp::ToolCallUpdateFields::new()
4350 .status(acp::ToolCallStatus::Completed)
4351 .raw_output("Hello!")
4352 )
4353 );
4354}
4355
4356#[gpui::test]
4357async fn test_send_no_retry_on_success(cx: &mut TestAppContext) {
4358 let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
4359 let fake_model = model.as_fake();
4360
4361 let mut events = thread
4362 .update(cx, |thread, cx| {
4363 thread.send(ClientUserMessageId::new(), ["Hello!"], cx)
4364 })
4365 .unwrap();
4366 cx.run_until_parked();
4367
4368 fake_model.send_last_completion_stream_text_chunk("Hey!");
4369 fake_model.end_last_completion_stream();
4370
4371 let mut retry_events = Vec::new();
4372 while let Some(Ok(event)) = events.next().await {
4373 match event {
4374 ThreadEvent::Retry(retry_status) => {
4375 retry_events.push(retry_status);
4376 }
4377 ThreadEvent::Stop(..) => break,
4378 _ => {}
4379 }
4380 }
4381
4382 assert_eq!(retry_events.len(), 0);
4383 thread.read_with(cx, |thread, _cx| {
4384 assert_eq!(
4385 thread.to_markdown(),
4386 indoc! {"
4387 ## User
4388
4389 Hello!
4390
4391 ## Assistant
4392
4393 Hey!
4394 "}
4395 )
4396 });
4397}
4398
4399#[gpui::test]
4400async fn test_send_retry_on_error(cx: &mut TestAppContext) {
4401 let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
4402 let fake_model = model.as_fake();
4403
4404 let mut events = thread
4405 .update(cx, |thread, cx| {
4406 thread.send(ClientUserMessageId::new(), ["Hello!"], cx)
4407 })
4408 .unwrap();
4409 cx.run_until_parked();
4410
4411 fake_model.send_last_completion_stream_text_chunk("Hey,");
4412 fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
4413 provider: LanguageModelProviderName::new("Anthropic"),
4414 retry_after: Some(Duration::from_secs(3)),
4415 });
4416 fake_model.end_last_completion_stream();
4417
4418 cx.executor().advance_clock(Duration::from_secs(3));
4419 cx.run_until_parked();
4420
4421 fake_model.send_last_completion_stream_text_chunk("there!");
4422 fake_model.end_last_completion_stream();
4423 cx.run_until_parked();
4424
4425 let mut retry_events = Vec::new();
4426 while let Some(Ok(event)) = events.next().await {
4427 match event {
4428 ThreadEvent::Retry(retry_status) => {
4429 retry_events.push(retry_status);
4430 }
4431 ThreadEvent::Stop(..) => break,
4432 _ => {}
4433 }
4434 }
4435
4436 assert_eq!(retry_events.len(), 1);
4437 assert!(matches!(
4438 retry_events[0],
4439 acp_thread::RetryStatus { attempt: 1, .. }
4440 ));
4441 thread.read_with(cx, |thread, _cx| {
4442 assert_eq!(
4443 thread.to_markdown(),
4444 indoc! {"
4445 ## User
4446
4447 Hello!
4448
4449 ## Assistant
4450
4451 Hey,
4452
4453 [resume]
4454
4455 ## Assistant
4456
4457 there!
4458 "}
4459 )
4460 });
4461}
4462
4463#[gpui::test]
4464async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
4465 let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
4466 let fake_model = model.as_fake();
4467
4468 let events = thread
4469 .update(cx, |thread, cx| {
4470 thread.add_tool(EchoTool);
4471 thread.send(ClientUserMessageId::new(), ["Call the echo tool!"], cx)
4472 })
4473 .unwrap();
4474 cx.run_until_parked();
4475
4476 let tool_use_1 = LanguageModelToolUse {
4477 id: "tool_1".into(),
4478 name: EchoTool::NAME.into(),
4479 raw_input: json!({"text": "test"}).to_string(),
4480 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
4481 is_input_complete: true,
4482 thought_signature: None,
4483 };
4484 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
4485 tool_use_1.clone(),
4486 ));
4487 fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded {
4488 provider: LanguageModelProviderName::new("Anthropic"),
4489 retry_after: Some(Duration::from_secs(3)),
4490 });
4491 fake_model.end_last_completion_stream();
4492
4493 cx.executor().advance_clock(Duration::from_secs(3));
4494 let completion = fake_model.pending_completions().pop().unwrap();
4495 assert_eq!(
4496 completion.messages[1..],
4497 vec![
4498 LanguageModelRequestMessage {
4499 role: Role::User,
4500 content: vec!["Call the echo tool!".into()],
4501 cache: false,
4502 reasoning_details: None,
4503 },
4504 LanguageModelRequestMessage {
4505 role: Role::Assistant,
4506 content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())],
4507 cache: false,
4508 reasoning_details: None,
4509 },
4510 LanguageModelRequestMessage {
4511 role: Role::User,
4512 content: vec![language_model::MessageContent::ToolResult(
4513 LanguageModelToolResult {
4514 tool_use_id: tool_use_1.id.clone(),
4515 tool_name: tool_use_1.name.clone(),
4516 is_error: false,
4517 content: vec!["test".into()],
4518 output: Some("test".into())
4519 }
4520 )],
4521 cache: true,
4522 reasoning_details: None,
4523 },
4524 ]
4525 );
4526
4527 fake_model.send_last_completion_stream_text_chunk("Done");
4528 fake_model.end_last_completion_stream();
4529 cx.run_until_parked();
4530 events.collect::<Vec<_>>().await;
4531 thread.read_with(cx, |thread, _cx| {
4532 assert_eq!(
4533 thread.last_received_or_pending_message().as_deref(),
4534 Some(&Message::Agent(AgentMessage {
4535 content: vec![AgentMessageContent::Text("Done".into())],
4536 tool_results: IndexMap::default(),
4537 reasoning_details: None,
4538 }))
4539 );
4540 })
4541}
4542
4543#[gpui::test]
4544async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) {
4545 let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
4546 let fake_model = model.as_fake();
4547
4548 let mut events = thread
4549 .update(cx, |thread, cx| {
4550 thread.send(ClientUserMessageId::new(), ["Hello!"], cx)
4551 })
4552 .unwrap();
4553 cx.run_until_parked();
4554
4555 for _ in 0..crate::thread::MAX_RETRY_ATTEMPTS + 1 {
4556 fake_model.send_last_completion_stream_error(
4557 LanguageModelCompletionError::ServerOverloaded {
4558 provider: LanguageModelProviderName::new("Anthropic"),
4559 retry_after: Some(Duration::from_secs(3)),
4560 },
4561 );
4562 fake_model.end_last_completion_stream();
4563 cx.executor().advance_clock(Duration::from_secs(3));
4564 cx.run_until_parked();
4565 }
4566
4567 let mut errors = Vec::new();
4568 let mut retry_events = Vec::new();
4569 while let Some(event) = events.next().await {
4570 match event {
4571 Ok(ThreadEvent::Retry(retry_status)) => {
4572 retry_events.push(retry_status);
4573 }
4574 Ok(ThreadEvent::Stop(..)) => break,
4575 Err(error) => errors.push(error),
4576 _ => {}
4577 }
4578 }
4579
4580 assert_eq!(
4581 retry_events.len(),
4582 crate::thread::MAX_RETRY_ATTEMPTS as usize
4583 );
4584 for i in 0..crate::thread::MAX_RETRY_ATTEMPTS as usize {
4585 assert_eq!(retry_events[i].attempt, i + 1);
4586 }
4587 assert_eq!(errors.len(), 1);
4588 let error = errors[0]
4589 .downcast_ref::<LanguageModelCompletionError>()
4590 .unwrap();
4591 assert!(matches!(
4592 error,
4593 LanguageModelCompletionError::ServerOverloaded { .. }
4594 ));
4595}
4596
4597#[gpui::test]
4598async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
4599 cx: &mut TestAppContext,
4600) {
4601 init_test(cx);
4602 always_allow_tools(cx);
4603
4604 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
4605 let fake_model = model.as_fake();
4606
4607 thread.update(cx, |thread, _cx| {
4608 thread.add_tool(StreamingEchoTool::new());
4609 });
4610
4611 let _events = thread
4612 .update(cx, |thread, cx| {
4613 thread.send(
4614 ClientUserMessageId::new(),
4615 ["Use the streaming_echo tool"],
4616 cx,
4617 )
4618 })
4619 .unwrap();
4620 cx.run_until_parked();
4621
4622 // Send a partial tool use (is_input_complete = false), simulating the LLM
4623 // streaming input for a tool.
4624 let tool_use = LanguageModelToolUse {
4625 id: "tool_1".into(),
4626 name: "streaming_echo".into(),
4627 raw_input: r#"{"text": "partial"}"#.into(),
4628 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
4629 is_input_complete: false,
4630 thought_signature: None,
4631 };
4632 fake_model
4633 .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
4634 cx.run_until_parked();
4635
4636 // Send a stream error WITHOUT ever sending is_input_complete = true.
4637 // Before the fix, this would deadlock: the tool waits for more partials
4638 // (or cancellation), run_turn_internal waits for the tool, and the sender
4639 // keeping the channel open lives inside RunningTurn.
4640 fake_model.send_last_completion_stream_error(
4641 LanguageModelCompletionError::UpstreamProviderError {
4642 message: "Internal server error".to_string(),
4643 status: http_client::StatusCode::INTERNAL_SERVER_ERROR,
4644 retry_after: None,
4645 },
4646 );
4647 fake_model.end_last_completion_stream();
4648
4649 // Advance past the retry delay so run_turn_internal retries.
4650 cx.executor().advance_clock(Duration::from_secs(5));
4651 cx.run_until_parked();
4652
4653 // The retry request should contain the streaming tool's error result,
4654 // proving the tool terminated and its result was forwarded.
4655 let completion = fake_model
4656 .pending_completions()
4657 .pop()
4658 .expect("No running turn");
4659 assert_eq!(
4660 completion.messages[1..],
4661 vec![
4662 LanguageModelRequestMessage {
4663 role: Role::User,
4664 content: vec!["Use the streaming_echo tool".into()],
4665 cache: false,
4666 reasoning_details: None,
4667 },
4668 LanguageModelRequestMessage {
4669 role: Role::Assistant,
4670 content: vec![language_model::MessageContent::ToolUse(tool_use.clone())],
4671 cache: false,
4672 reasoning_details: None,
4673 },
4674 LanguageModelRequestMessage {
4675 role: Role::User,
4676 content: vec![language_model::MessageContent::ToolResult(
4677 LanguageModelToolResult {
4678 tool_use_id: tool_use.id.clone(),
4679 tool_name: tool_use.name,
4680 is_error: true,
4681 content: vec!["tool input was not fully received".into(),],
4682 output: Some("tool input was not fully received".into()),
4683 }
4684 )],
4685 cache: true,
4686 reasoning_details: None,
4687 },
4688 ]
4689 );
4690
4691 // Finish the retry round so the turn completes cleanly.
4692 fake_model.send_last_completion_stream_text_chunk("Done");
4693 fake_model.end_last_completion_stream();
4694 cx.run_until_parked();
4695
4696 thread.read_with(cx, |thread, _cx| {
4697 assert!(
4698 thread.is_turn_complete(),
4699 "Thread should not be stuck; the turn should have completed",
4700 );
4701 });
4702}
4703
4704#[gpui::test]
4705async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool(
4706 cx: &mut TestAppContext,
4707) {
4708 init_test(cx);
4709 always_allow_tools(cx);
4710
4711 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
4712 let fake_model = model.as_fake();
4713
4714 thread.update(cx, |thread, _cx| {
4715 thread.add_tool(StreamingJsonErrorContextTool);
4716 });
4717
4718 let _events = thread
4719 .update(cx, |thread, cx| {
4720 thread.send(
4721 ClientUserMessageId::new(),
4722 ["Use the streaming_json_error_context tool"],
4723 cx,
4724 )
4725 })
4726 .unwrap();
4727 cx.run_until_parked();
4728
4729 let tool_use = LanguageModelToolUse {
4730 id: "tool_1".into(),
4731 name: StreamingJsonErrorContextTool::NAME.into(),
4732 raw_input: r#"{"text": "partial"#.into(),
4733 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
4734 is_input_complete: false,
4735 thought_signature: None,
4736 };
4737 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use));
4738 cx.run_until_parked();
4739
4740 fake_model.send_last_completion_stream_event(
4741 LanguageModelCompletionEvent::ToolUseJsonParseError {
4742 id: "tool_1".into(),
4743 tool_name: StreamingJsonErrorContextTool::NAME.into(),
4744 raw_input: r#"{"text": "partial"#.into(),
4745 json_parse_error: "EOF while parsing a string at line 1 column 17".into(),
4746 },
4747 );
4748 fake_model
4749 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse));
4750 fake_model.end_last_completion_stream();
4751 cx.run_until_parked();
4752
4753 cx.executor().advance_clock(Duration::from_secs(5));
4754 cx.run_until_parked();
4755
4756 let completion = fake_model
4757 .pending_completions()
4758 .pop()
4759 .expect("No running turn");
4760
4761 let tool_results: Vec<_> = completion
4762 .messages
4763 .iter()
4764 .flat_map(|message| &message.content)
4765 .filter_map(|content| match content {
4766 MessageContent::ToolResult(result)
4767 if result.tool_use_id == language_model::LanguageModelToolUseId::from("tool_1") =>
4768 {
4769 Some(result)
4770 }
4771 _ => None,
4772 })
4773 .collect();
4774
4775 assert_eq!(
4776 tool_results.len(),
4777 1,
4778 "Expected exactly 1 tool result for tool_1, got {}: {:#?}",
4779 tool_results.len(),
4780 tool_results
4781 );
4782
4783 let result = tool_results[0];
4784 assert!(result.is_error);
4785 let content_text = result.text_contents();
4786 assert!(
4787 content_text.contains("Saw partial text 'partial' before invalid JSON"),
4788 "Expected tool-enriched partial context, got: {content_text}"
4789 );
4790 assert!(
4791 content_text
4792 .contains("Error parsing input JSON: EOF while parsing a string at line 1 column 17"),
4793 "Expected forwarded JSON parse error, got: {content_text}"
4794 );
4795 assert!(
4796 !content_text.contains("tool input was not fully received"),
4797 "Should not contain orphaned sender error, got: {content_text}"
4798 );
4799
4800 fake_model.send_last_completion_stream_text_chunk("Done");
4801 fake_model.end_last_completion_stream();
4802 cx.run_until_parked();
4803
4804 thread.read_with(cx, |thread, _cx| {
4805 assert!(
4806 thread.is_turn_complete(),
4807 "Thread should not be stuck; the turn should have completed",
4808 );
4809 });
4810}
4811
4812/// Filters out the stop events for asserting against in tests
4813fn stop_events(result_events: Vec<Result<ThreadEvent>>) -> Vec<acp::StopReason> {
4814 result_events
4815 .into_iter()
4816 .filter_map(|event| match event.unwrap() {
4817 ThreadEvent::Stop(stop_reason) => Some(stop_reason),
4818 _ => None,
4819 })
4820 .collect()
4821}
4822
4823struct ThreadTest {
4824 model: Arc<dyn LanguageModel>,
4825 thread: Entity<Thread>,
4826 project_context: Entity<ProjectContext>,
4827 context_server_store: Entity<ContextServerStore>,
4828 fs: Arc<FakeFs>,
4829}
4830
4831enum TestModel {
4832 Sonnet4,
4833 Fake,
4834}
4835
4836impl TestModel {
4837 fn id(&self) -> LanguageModelId {
4838 match self {
4839 TestModel::Sonnet4 => LanguageModelId("claude-sonnet-4-latest".into()),
4840 TestModel::Fake => unreachable!(),
4841 }
4842 }
4843}
4844
4845async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
4846 cx.executor().allow_parking();
4847
4848 let fs = FakeFs::new(cx.background_executor.clone());
4849 fs.create_dir(paths::settings_file().parent().unwrap())
4850 .await
4851 .unwrap();
4852 fs.insert_file(
4853 paths::settings_file(),
4854 json!({
4855 "agent": {
4856 "default_profile": "test-profile",
4857 "profiles": {
4858 "test-profile": {
4859 "name": "Test Profile",
4860 "tools": {
4861 EchoTool::NAME: true,
4862 DelayTool::NAME: true,
4863 WordListTool::NAME: true,
4864 ToolRequiringPermission::NAME: true,
4865 ToolRequiringPermission2::NAME: true,
4866 InfiniteTool::NAME: true,
4867 CancellationAwareTool::NAME: true,
4868 StreamingEchoTool::NAME: true,
4869 StreamingJsonErrorContextTool::NAME: true,
4870 StreamingFailingEchoTool::NAME: true,
4871 TerminalTool::NAME: true,
4872 }
4873 }
4874 }
4875 }
4876 })
4877 .to_string()
4878 .into_bytes(),
4879 )
4880 .await;
4881
4882 cx.update(|cx| {
4883 settings::init(cx);
4884
4885 match model {
4886 TestModel::Fake => {}
4887 TestModel::Sonnet4 => {
4888 gpui_tokio::init(cx);
4889 let http_client = ReqwestClient::user_agent("agent tests").unwrap();
4890 cx.set_http_client(Arc::new(http_client));
4891 let client = Client::production(cx);
4892 let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
4893 language_model::init(cx);
4894 RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
4895 language_models::init(user_store, client.clone(), cx);
4896 }
4897 };
4898
4899 watch_settings(fs.clone(), cx);
4900 });
4901
4902 let templates = Templates::new();
4903
4904 fs.insert_tree(path!("/test"), json!({})).await;
4905 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
4906
4907 let model = cx
4908 .update(|cx| {
4909 if let TestModel::Fake = model {
4910 Task::ready(Arc::new(FakeLanguageModel::default()) as Arc<_>)
4911 } else {
4912 let model_id = model.id();
4913 let models = LanguageModelRegistry::read_global(cx);
4914 let model = models
4915 .available_models(cx)
4916 .find(|model| model.id() == model_id)
4917 .unwrap();
4918
4919 let provider = models.provider(&model.provider_id()).unwrap();
4920 let authenticated = provider.authenticate(cx);
4921
4922 cx.spawn(async move |_cx| {
4923 authenticated.await.unwrap();
4924 model
4925 })
4926 }
4927 })
4928 .await;
4929
4930 let project_context = cx.new(|_cx| ProjectContext::default());
4931 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
4932 let context_server_registry =
4933 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
4934 let thread = cx.new(|cx| {
4935 Thread::new(
4936 project,
4937 project_context.clone(),
4938 context_server_registry,
4939 templates,
4940 Some(model.clone()),
4941 cx,
4942 )
4943 });
4944 ThreadTest {
4945 model,
4946 thread,
4947 project_context,
4948 context_server_store,
4949 fs,
4950 }
4951}
4952
4953#[cfg(test)]
4954#[ctor::ctor(unsafe)]
4955fn init_logger() {
4956 if std::env::var("RUST_LOG").is_ok() {
4957 env_logger::init();
4958 }
4959}
4960
4961fn watch_settings(fs: Arc<dyn Fs>, cx: &mut App) {
4962 let fs = fs.clone();
4963 cx.spawn({
4964 async move |cx| {
4965 let (mut new_settings_content_rx, watcher_task) = settings::watch_config_file(
4966 cx.background_executor(),
4967 fs,
4968 paths::settings_file().clone(),
4969 );
4970 let _watcher_task = watcher_task;
4971
4972 while let Some(new_settings_content) = new_settings_content_rx.next().await {
4973 cx.update(|cx| {
4974 SettingsStore::update_global(cx, |settings, cx| {
4975 settings.set_user_settings(&new_settings_content, cx)
4976 })
4977 })
4978 .ok();
4979 }
4980 }
4981 })
4982 .detach();
4983}
4984
4985fn tool_names_for_completion(completion: &LanguageModelRequest) -> Vec<String> {
4986 completion
4987 .tools
4988 .iter()
4989 .map(|tool| tool.name.clone())
4990 .collect()
4991}
4992
4993fn setup_context_server(
4994 name: &'static str,
4995 tools: Vec<context_server::types::Tool>,
4996 context_server_store: &Entity<ContextServerStore>,
4997 cx: &mut TestAppContext,
4998) -> mpsc::UnboundedReceiver<(
4999 context_server::types::CallToolParams,
5000 oneshot::Sender<context_server::types::CallToolResponse>,
5001)> {
5002 cx.update(|cx| {
5003 let mut settings = ProjectSettings::get_global(cx).clone();
5004 settings.context_servers.insert(
5005 name.into(),
5006 project::project_settings::ContextServerSettings::Stdio {
5007 enabled: true,
5008 remote: false,
5009 command: ContextServerCommand {
5010 path: "somebinary".into(),
5011 args: Vec::new(),
5012 env: None,
5013 timeout: None,
5014 },
5015 },
5016 );
5017 ProjectSettings::override_global(settings, cx);
5018 });
5019
5020 let (mcp_tool_calls_tx, mcp_tool_calls_rx) = mpsc::unbounded();
5021 let fake_transport = context_server::test::create_fake_transport(name, cx.executor())
5022 .on_request::<context_server::types::requests::Initialize, _>(move |_params| async move {
5023 context_server::types::InitializeResponse {
5024 protocol_version: context_server::types::ProtocolVersion(
5025 context_server::types::LATEST_PROTOCOL_VERSION.to_string(),
5026 ),
5027 server_info: context_server::types::Implementation {
5028 name: name.into(),
5029 title: None,
5030 version: "1.0.0".to_string(),
5031 description: None,
5032 },
5033 capabilities: context_server::types::ServerCapabilities {
5034 tools: Some(context_server::types::ToolsCapabilities {
5035 list_changed: Some(true),
5036 }),
5037 ..Default::default()
5038 },
5039 meta: None,
5040 }
5041 })
5042 .on_request::<context_server::types::requests::ListTools, _>(move |_params| {
5043 let tools = tools.clone();
5044 async move {
5045 context_server::types::ListToolsResponse {
5046 tools,
5047 next_cursor: None,
5048 meta: None,
5049 }
5050 }
5051 })
5052 .on_request::<context_server::types::requests::CallTool, _>(move |params| {
5053 let mcp_tool_calls_tx = mcp_tool_calls_tx.clone();
5054 async move {
5055 let (response_tx, response_rx) = oneshot::channel();
5056 mcp_tool_calls_tx
5057 .unbounded_send((params, response_tx))
5058 .unwrap();
5059 response_rx.await.unwrap()
5060 }
5061 });
5062 context_server_store.update(cx, |store, cx| {
5063 store.start_server(
5064 Arc::new(ContextServer::new(
5065 ContextServerId(name.into()),
5066 Arc::new(fake_transport),
5067 )),
5068 cx,
5069 );
5070 });
5071 cx.run_until_parked();
5072 mcp_tool_calls_rx
5073}
5074
5075#[gpui::test]
5076async fn test_tokens_before_message(cx: &mut TestAppContext) {
5077 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
5078 let fake_model = model.as_fake();
5079
5080 // First message
5081 let message_1_id = ClientUserMessageId::new();
5082 thread
5083 .update(cx, |thread, cx| {
5084 thread.send(message_1_id.clone(), ["First message"], cx)
5085 })
5086 .unwrap();
5087 cx.run_until_parked();
5088
5089 // Before any response, tokens_before_message should return None for first message
5090 thread.read_with(cx, |thread, _| {
5091 assert_eq!(
5092 thread.tokens_before_message(&message_1_id),
5093 None,
5094 "First message should have no tokens before it"
5095 );
5096 });
5097
5098 // Complete first message with usage
5099 fake_model.send_last_completion_stream_text_chunk("Response 1");
5100 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
5101 language_model::TokenUsage {
5102 input_tokens: 100,
5103 output_tokens: 50,
5104 cache_creation_input_tokens: 0,
5105 cache_read_input_tokens: 0,
5106 },
5107 ));
5108 fake_model.end_last_completion_stream();
5109 cx.run_until_parked();
5110
5111 // First message still has no tokens before it
5112 thread.read_with(cx, |thread, _| {
5113 assert_eq!(
5114 thread.tokens_before_message(&message_1_id),
5115 None,
5116 "First message should still have no tokens before it after response"
5117 );
5118 });
5119
5120 // Second message
5121 let message_2_id = ClientUserMessageId::new();
5122 thread
5123 .update(cx, |thread, cx| {
5124 thread.send(message_2_id.clone(), ["Second message"], cx)
5125 })
5126 .unwrap();
5127 cx.run_until_parked();
5128
5129 // Second message should have first message's input tokens before it
5130 thread.read_with(cx, |thread, _| {
5131 assert_eq!(
5132 thread.tokens_before_message(&message_2_id),
5133 Some(100),
5134 "Second message should have 100 tokens before it (from first request)"
5135 );
5136 });
5137
5138 // Complete second message
5139 fake_model.send_last_completion_stream_text_chunk("Response 2");
5140 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
5141 language_model::TokenUsage {
5142 input_tokens: 250, // Total for this request (includes previous context)
5143 output_tokens: 75,
5144 cache_creation_input_tokens: 0,
5145 cache_read_input_tokens: 0,
5146 },
5147 ));
5148 fake_model.end_last_completion_stream();
5149 cx.run_until_parked();
5150
5151 // Third message
5152 let message_3_id = ClientUserMessageId::new();
5153 thread
5154 .update(cx, |thread, cx| {
5155 thread.send(message_3_id.clone(), ["Third message"], cx)
5156 })
5157 .unwrap();
5158 cx.run_until_parked();
5159
5160 // Third message should have second message's input tokens (250) before it
5161 thread.read_with(cx, |thread, _| {
5162 assert_eq!(
5163 thread.tokens_before_message(&message_3_id),
5164 Some(250),
5165 "Third message should have 250 tokens before it (from second request)"
5166 );
5167 // Second message should still have 100
5168 assert_eq!(
5169 thread.tokens_before_message(&message_2_id),
5170 Some(100),
5171 "Second message should still have 100 tokens before it"
5172 );
5173 // First message still has none
5174 assert_eq!(
5175 thread.tokens_before_message(&message_1_id),
5176 None,
5177 "First message should still have no tokens before it"
5178 );
5179 });
5180}
5181
5182#[gpui::test]
5183async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) {
5184 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
5185 let fake_model = model.as_fake();
5186
5187 // Set up three messages with responses
5188 let message_1_id = ClientUserMessageId::new();
5189 thread
5190 .update(cx, |thread, cx| {
5191 thread.send(message_1_id.clone(), ["Message 1"], cx)
5192 })
5193 .unwrap();
5194 cx.run_until_parked();
5195 fake_model.send_last_completion_stream_text_chunk("Response 1");
5196 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
5197 language_model::TokenUsage {
5198 input_tokens: 100,
5199 output_tokens: 50,
5200 cache_creation_input_tokens: 0,
5201 cache_read_input_tokens: 0,
5202 },
5203 ));
5204 fake_model.end_last_completion_stream();
5205 cx.run_until_parked();
5206
5207 let message_2_id = ClientUserMessageId::new();
5208 thread
5209 .update(cx, |thread, cx| {
5210 thread.send(message_2_id.clone(), ["Message 2"], cx)
5211 })
5212 .unwrap();
5213 cx.run_until_parked();
5214 fake_model.send_last_completion_stream_text_chunk("Response 2");
5215 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
5216 language_model::TokenUsage {
5217 input_tokens: 250,
5218 output_tokens: 75,
5219 cache_creation_input_tokens: 0,
5220 cache_read_input_tokens: 0,
5221 },
5222 ));
5223 fake_model.end_last_completion_stream();
5224 cx.run_until_parked();
5225
5226 // Verify initial state
5227 thread.read_with(cx, |thread, _| {
5228 assert_eq!(thread.tokens_before_message(&message_2_id), Some(100));
5229 });
5230
5231 // Truncate at message 2 (removes message 2 and everything after)
5232 thread
5233 .update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx))
5234 .unwrap();
5235 cx.run_until_parked();
5236
5237 // After truncation, message_2_id no longer exists, so lookup should return None
5238 thread.read_with(cx, |thread, _| {
5239 assert_eq!(
5240 thread.tokens_before_message(&message_2_id),
5241 None,
5242 "After truncation, message 2 no longer exists"
5243 );
5244 // Message 1 still exists but has no tokens before it
5245 assert_eq!(
5246 thread.tokens_before_message(&message_1_id),
5247 None,
5248 "First message still has no tokens before it"
5249 );
5250 });
5251}
5252
5253#[gpui::test]
5254async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
5255 init_test(cx);
5256
5257 let fs = FakeFs::new(cx.executor());
5258 fs.insert_tree("/root", json!({})).await;
5259 let project = Project::test(fs, ["/root".as_ref()], cx).await;
5260
5261 // Test 1: Deny rule blocks command
5262 {
5263 let environment = Rc::new(cx.update(|cx| {
5264 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
5265 }));
5266
5267 cx.update(|cx| {
5268 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
5269 settings.tool_permissions.tools.insert(
5270 TerminalTool::NAME.into(),
5271 agent_settings::ToolRules {
5272 default: Some(settings::ToolPermissionMode::Confirm),
5273 always_allow: vec![],
5274 always_deny: vec![
5275 agent_settings::CompiledRegex::new(r"rm\s+-rf", false).unwrap(),
5276 ],
5277 always_confirm: vec![],
5278 invalid_patterns: vec![],
5279 },
5280 );
5281 agent_settings::AgentSettings::override_global(settings, cx);
5282 });
5283
5284 #[allow(clippy::arc_with_non_send_sync)]
5285 let tool = Arc::new(crate::TerminalTool::new(project.clone(), environment));
5286 let (event_stream, _rx) = crate::ToolCallEventStream::test();
5287
5288 let task = cx.update(|cx| {
5289 tool.run(
5290 ToolInput::resolved(crate::TerminalToolInput {
5291 command: "rm -rf /".to_string(),
5292 cd: ".".to_string(),
5293 timeout_ms: None,
5294 ..Default::default()
5295 }),
5296 event_stream,
5297 cx,
5298 )
5299 });
5300
5301 let result = task.await;
5302 assert!(
5303 result.is_err(),
5304 "expected command to be blocked by deny rule"
5305 );
5306 let err_msg = result.unwrap_err().to_lowercase();
5307 assert!(
5308 err_msg.contains("blocked"),
5309 "error should mention the command was blocked"
5310 );
5311 }
5312
5313 // Test 2: Allow rule skips confirmation (and overrides default: Deny)
5314 {
5315 let environment = Rc::new(cx.update(|cx| {
5316 FakeThreadEnvironment::default()
5317 .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
5318 }));
5319
5320 cx.update(|cx| {
5321 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
5322 settings.tool_permissions.tools.insert(
5323 TerminalTool::NAME.into(),
5324 agent_settings::ToolRules {
5325 default: Some(settings::ToolPermissionMode::Deny),
5326 always_allow: vec![
5327 agent_settings::CompiledRegex::new(r"^echo\s", false).unwrap(),
5328 ],
5329 always_deny: vec![],
5330 always_confirm: vec![],
5331 invalid_patterns: vec![],
5332 },
5333 );
5334 agent_settings::AgentSettings::override_global(settings, cx);
5335 });
5336
5337 #[allow(clippy::arc_with_non_send_sync)]
5338 let tool = Arc::new(crate::TerminalTool::new(project.clone(), environment));
5339 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
5340
5341 let task = cx.update(|cx| {
5342 tool.run(
5343 ToolInput::resolved(crate::TerminalToolInput {
5344 command: "echo hello".to_string(),
5345 cd: ".".to_string(),
5346 timeout_ms: None,
5347 ..Default::default()
5348 }),
5349 event_stream,
5350 cx,
5351 )
5352 });
5353
5354 let update = rx.expect_update_fields().await;
5355 assert!(
5356 update.content.iter().any(|blocks| {
5357 blocks
5358 .iter()
5359 .any(|c| matches!(c, acp::ToolCallContent::Terminal(_)))
5360 }),
5361 "expected terminal content (allow rule should skip confirmation and override default deny)"
5362 );
5363
5364 let result = task.await;
5365 assert!(
5366 result.is_ok(),
5367 "expected command to succeed without confirmation"
5368 );
5369 }
5370
5371 // Test 3: global default: allow does NOT override always_confirm patterns
5372 {
5373 let environment = Rc::new(cx.update(|cx| {
5374 FakeThreadEnvironment::default()
5375 .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
5376 }));
5377
5378 cx.update(|cx| {
5379 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
5380 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
5381 settings.tool_permissions.tools.insert(
5382 TerminalTool::NAME.into(),
5383 agent_settings::ToolRules {
5384 default: Some(settings::ToolPermissionMode::Allow),
5385 always_allow: vec![],
5386 always_deny: vec![],
5387 always_confirm: vec![
5388 agent_settings::CompiledRegex::new(r"sudo", false).unwrap(),
5389 ],
5390 invalid_patterns: vec![],
5391 },
5392 );
5393 agent_settings::AgentSettings::override_global(settings, cx);
5394 });
5395
5396 #[allow(clippy::arc_with_non_send_sync)]
5397 let tool = Arc::new(crate::TerminalTool::new(project.clone(), environment));
5398 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
5399
5400 let _task = cx.update(|cx| {
5401 tool.run(
5402 ToolInput::resolved(crate::TerminalToolInput {
5403 command: "sudo rm file".to_string(),
5404 cd: ".".to_string(),
5405 timeout_ms: None,
5406 ..Default::default()
5407 }),
5408 event_stream,
5409 cx,
5410 )
5411 });
5412
5413 // With global default: allow, confirm patterns are still respected
5414 // The expect_authorization() call will panic if no authorization is requested,
5415 // which validates that the confirm pattern still triggers confirmation
5416 let _auth = rx.expect_authorization().await;
5417
5418 drop(_task);
5419 }
5420
5421 // Test 4: tool-specific default: deny is respected even with global default: allow
5422 {
5423 let environment = Rc::new(cx.update(|cx| {
5424 FakeThreadEnvironment::default()
5425 .with_terminal(FakeTerminalHandle::new_with_immediate_exit(cx, 0))
5426 }));
5427
5428 cx.update(|cx| {
5429 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
5430 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
5431 settings.tool_permissions.tools.insert(
5432 TerminalTool::NAME.into(),
5433 agent_settings::ToolRules {
5434 default: Some(settings::ToolPermissionMode::Deny),
5435 always_allow: vec![],
5436 always_deny: vec![],
5437 always_confirm: vec![],
5438 invalid_patterns: vec![],
5439 },
5440 );
5441 agent_settings::AgentSettings::override_global(settings, cx);
5442 });
5443
5444 #[allow(clippy::arc_with_non_send_sync)]
5445 let tool = Arc::new(crate::TerminalTool::new(project.clone(), environment));
5446 let (event_stream, _rx) = crate::ToolCallEventStream::test();
5447
5448 let task = cx.update(|cx| {
5449 tool.run(
5450 ToolInput::resolved(crate::TerminalToolInput {
5451 command: "echo hello".to_string(),
5452 cd: ".".to_string(),
5453 timeout_ms: None,
5454 ..Default::default()
5455 }),
5456 event_stream,
5457 cx,
5458 )
5459 });
5460
5461 // tool-specific default: deny is respected even with global default: allow
5462 let result = task.await;
5463 assert!(
5464 result.is_err(),
5465 "expected command to be blocked by tool-specific deny default"
5466 );
5467 let err_msg = result.unwrap_err().to_lowercase();
5468 assert!(
5469 err_msg.contains("disabled"),
5470 "error should mention the tool is disabled, got: {err_msg}"
5471 );
5472 }
5473}
5474
5475#[gpui::test]
5476async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
5477 init_test(cx);
5478 cx.update(|cx| {
5479 LanguageModelRegistry::test(cx);
5480 });
5481 cx.update(|cx| {
5482 cx.update_flags(true, vec!["subagents".to_string()]);
5483 });
5484
5485 let fs = FakeFs::new(cx.executor());
5486 fs.insert_tree(
5487 "/",
5488 json!({
5489 "a": {
5490 "b.md": "Lorem"
5491 }
5492 }),
5493 )
5494 .await;
5495 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5496 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5497 let agent =
5498 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5499 let connection = Rc::new(NativeAgentConnection(agent.clone()));
5500
5501 let acp_thread = cx
5502 .update(|cx| {
5503 connection
5504 .clone()
5505 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
5506 })
5507 .await
5508 .unwrap();
5509 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5510 let thread = agent.read_with(cx, |agent, _| {
5511 agent.sessions.get(&session_id).unwrap().thread.clone()
5512 });
5513 let model = Arc::new(FakeLanguageModel::default());
5514
5515 // Ensure empty threads are not saved, even if they get mutated.
5516 thread.update(cx, |thread, cx| {
5517 thread.set_model(model.clone(), cx);
5518 });
5519 cx.run_until_parked();
5520
5521 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx));
5522 cx.run_until_parked();
5523 model.send_last_completion_stream_text_chunk("spawning subagent");
5524 let subagent_tool_input = SpawnAgentToolInput {
5525 label: "label".to_string(),
5526 message: "subagent task prompt".to_string(),
5527 session_id: None,
5528 };
5529 let subagent_tool_use = LanguageModelToolUse {
5530 id: "subagent_1".into(),
5531 name: SpawnAgentTool::NAME.into(),
5532 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
5533 input: language_model::LanguageModelToolUseInput::Json(
5534 serde_json::to_value(&subagent_tool_input).unwrap(),
5535 ),
5536 is_input_complete: true,
5537 thought_signature: None,
5538 };
5539 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
5540 subagent_tool_use,
5541 ));
5542 model.end_last_completion_stream();
5543
5544 cx.run_until_parked();
5545
5546 let subagent_session_id = thread.read_with(cx, |thread, cx| {
5547 thread
5548 .running_subagent_ids(cx)
5549 .get(0)
5550 .expect("subagent thread should be running")
5551 .clone()
5552 });
5553
5554 let subagent_thread = agent.read_with(cx, |agent, _cx| {
5555 agent
5556 .sessions
5557 .get(&subagent_session_id)
5558 .expect("subagent session should exist")
5559 .acp_thread
5560 .clone()
5561 });
5562
5563 model.send_last_completion_stream_text_chunk("subagent task response");
5564 model.end_last_completion_stream();
5565
5566 cx.run_until_parked();
5567
5568 assert_eq!(
5569 subagent_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)),
5570 indoc! {"
5571 ## User
5572
5573 subagent task prompt
5574
5575 ## Assistant
5576
5577 subagent task response
5578
5579 "}
5580 );
5581
5582 model.send_last_completion_stream_text_chunk("Response");
5583 model.end_last_completion_stream();
5584
5585 send.await.unwrap();
5586
5587 assert_eq!(
5588 acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)),
5589 indoc! {r#"
5590 ## User
5591
5592 Prompt
5593
5594 ## Assistant
5595
5596 spawning subagent
5597
5598 **Tool Call: label**
5599 Status: Completed
5600
5601 subagent task response
5602
5603 ## Assistant
5604
5605 Response
5606
5607 "#},
5608 );
5609}
5610
5611#[gpui::test]
5612async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppContext) {
5613 init_test(cx);
5614 cx.update(|cx| {
5615 LanguageModelRegistry::test(cx);
5616 });
5617 cx.update(|cx| {
5618 cx.update_flags(true, vec!["subagents".to_string()]);
5619 });
5620
5621 let fs = FakeFs::new(cx.executor());
5622 fs.insert_tree(
5623 "/",
5624 json!({
5625 "a": {
5626 "b.md": "Lorem"
5627 }
5628 }),
5629 )
5630 .await;
5631 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5632 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5633 let agent =
5634 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5635 let connection = Rc::new(NativeAgentConnection(agent.clone()));
5636
5637 let acp_thread = cx
5638 .update(|cx| {
5639 connection
5640 .clone()
5641 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
5642 })
5643 .await
5644 .unwrap();
5645 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5646 let thread = agent.read_with(cx, |agent, _| {
5647 agent.sessions.get(&session_id).unwrap().thread.clone()
5648 });
5649 let model = Arc::new(FakeLanguageModel::default());
5650
5651 // Ensure empty threads are not saved, even if they get mutated.
5652 thread.update(cx, |thread, cx| {
5653 thread.set_model(model.clone(), cx);
5654 });
5655 cx.run_until_parked();
5656
5657 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx));
5658 cx.run_until_parked();
5659 model.send_last_completion_stream_text_chunk("spawning subagent");
5660 let subagent_tool_input = SpawnAgentToolInput {
5661 label: "label".to_string(),
5662 message: "subagent task prompt".to_string(),
5663 session_id: None,
5664 };
5665 let subagent_tool_use = LanguageModelToolUse {
5666 id: "subagent_1".into(),
5667 name: SpawnAgentTool::NAME.into(),
5668 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
5669 input: language_model::LanguageModelToolUseInput::Json(
5670 serde_json::to_value(&subagent_tool_input).unwrap(),
5671 ),
5672 is_input_complete: true,
5673 thought_signature: None,
5674 };
5675 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
5676 subagent_tool_use,
5677 ));
5678 model.end_last_completion_stream();
5679
5680 cx.run_until_parked();
5681
5682 let subagent_session_id = thread.read_with(cx, |thread, cx| {
5683 thread
5684 .running_subagent_ids(cx)
5685 .get(0)
5686 .expect("subagent thread should be running")
5687 .clone()
5688 });
5689
5690 let subagent_thread = agent.read_with(cx, |agent, _cx| {
5691 agent
5692 .sessions
5693 .get(&subagent_session_id)
5694 .expect("subagent session should exist")
5695 .acp_thread
5696 .clone()
5697 });
5698
5699 model.send_last_completion_stream_text_chunk("subagent task response 1");
5700 model.send_last_completion_stream_event(LanguageModelCompletionEvent::Thinking {
5701 text: "thinking more about the subagent task".into(),
5702 signature: None,
5703 });
5704 model.send_last_completion_stream_text_chunk("subagent task response 2");
5705 model.end_last_completion_stream();
5706
5707 cx.run_until_parked();
5708
5709 assert_eq!(
5710 subagent_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)),
5711 indoc! {"
5712 ## User
5713
5714 subagent task prompt
5715
5716 ## Assistant
5717
5718 subagent task response 1
5719
5720 <thinking>
5721 thinking more about the subagent task
5722 </thinking>
5723
5724 subagent task response 2
5725
5726 "}
5727 );
5728
5729 model.send_last_completion_stream_text_chunk("Response");
5730 model.end_last_completion_stream();
5731
5732 send.await.unwrap();
5733
5734 assert_eq!(
5735 acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)),
5736 indoc! {r#"
5737 ## User
5738
5739 Prompt
5740
5741 ## Assistant
5742
5743 spawning subagent
5744
5745 **Tool Call: label**
5746 Status: Completed
5747
5748 subagent task response 1
5749
5750 subagent task response 2
5751
5752 ## Assistant
5753
5754 Response
5755
5756 "#},
5757 );
5758}
5759
5760#[gpui::test]
5761async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAppContext) {
5762 init_test(cx);
5763 cx.update(|cx| {
5764 LanguageModelRegistry::test(cx);
5765 });
5766 cx.update(|cx| {
5767 cx.update_flags(true, vec!["subagents".to_string()]);
5768 });
5769
5770 let fs = FakeFs::new(cx.executor());
5771 fs.insert_tree(
5772 "/",
5773 json!({
5774 "a": {
5775 "b.md": "Lorem"
5776 }
5777 }),
5778 )
5779 .await;
5780 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5781 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5782 let agent =
5783 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5784 let connection = Rc::new(NativeAgentConnection(agent.clone()));
5785
5786 let acp_thread = cx
5787 .update(|cx| {
5788 connection
5789 .clone()
5790 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
5791 })
5792 .await
5793 .unwrap();
5794 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5795 let thread = agent.read_with(cx, |agent, _| {
5796 agent.sessions.get(&session_id).unwrap().thread.clone()
5797 });
5798 let model = Arc::new(FakeLanguageModel::default());
5799
5800 // Ensure empty threads are not saved, even if they get mutated.
5801 thread.update(cx, |thread, cx| {
5802 thread.set_model(model.clone(), cx);
5803 });
5804 cx.run_until_parked();
5805
5806 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx));
5807 cx.run_until_parked();
5808 model.send_last_completion_stream_text_chunk("spawning subagent");
5809 let subagent_tool_input = SpawnAgentToolInput {
5810 label: "label".to_string(),
5811 message: "subagent task prompt".to_string(),
5812 session_id: None,
5813 };
5814 let subagent_tool_use = LanguageModelToolUse {
5815 id: "subagent_1".into(),
5816 name: SpawnAgentTool::NAME.into(),
5817 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
5818 input: language_model::LanguageModelToolUseInput::Json(
5819 serde_json::to_value(&subagent_tool_input).unwrap(),
5820 ),
5821 is_input_complete: true,
5822 thought_signature: None,
5823 };
5824 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
5825 subagent_tool_use,
5826 ));
5827 model.end_last_completion_stream();
5828
5829 cx.run_until_parked();
5830
5831 let subagent_session_id = thread.read_with(cx, |thread, cx| {
5832 thread
5833 .running_subagent_ids(cx)
5834 .get(0)
5835 .expect("subagent thread should be running")
5836 .clone()
5837 });
5838 let subagent_acp_thread = agent.read_with(cx, |agent, _cx| {
5839 agent
5840 .sessions
5841 .get(&subagent_session_id)
5842 .expect("subagent session should exist")
5843 .acp_thread
5844 .clone()
5845 });
5846
5847 // model.send_last_completion_stream_text_chunk("subagent task response");
5848 // model.end_last_completion_stream();
5849
5850 // cx.run_until_parked();
5851
5852 acp_thread.update(cx, |thread, cx| thread.cancel(cx)).await;
5853
5854 cx.run_until_parked();
5855
5856 send.await.unwrap();
5857
5858 acp_thread.read_with(cx, |thread, cx| {
5859 assert_eq!(thread.status(), ThreadStatus::Idle);
5860 assert_eq!(
5861 thread.to_markdown(cx),
5862 indoc! {"
5863 ## User
5864
5865 Prompt
5866
5867 ## Assistant
5868
5869 spawning subagent
5870
5871 **Tool Call: label**
5872 Status: Canceled
5873
5874 "}
5875 );
5876 });
5877 subagent_acp_thread.read_with(cx, |thread, cx| {
5878 assert_eq!(thread.status(), ThreadStatus::Idle);
5879 assert_eq!(
5880 thread.to_markdown(cx),
5881 indoc! {"
5882 ## User
5883
5884 subagent task prompt
5885
5886 "}
5887 );
5888 });
5889}
5890
5891#[gpui::test]
5892async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
5893 init_test(cx);
5894 cx.update(|cx| {
5895 LanguageModelRegistry::test(cx);
5896 });
5897 cx.update(|cx| {
5898 cx.update_flags(true, vec!["subagents".to_string()]);
5899 });
5900
5901 let fs = FakeFs::new(cx.executor());
5902 fs.insert_tree(
5903 "/",
5904 json!({
5905 "a": {
5906 "b.md": "Lorem"
5907 }
5908 }),
5909 )
5910 .await;
5911 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
5912 let thread_store = cx.new(|cx| ThreadStore::new(cx));
5913 let agent =
5914 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
5915 let connection = Rc::new(NativeAgentConnection(agent.clone()));
5916
5917 let acp_thread = cx
5918 .update(|cx| {
5919 connection
5920 .clone()
5921 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
5922 })
5923 .await
5924 .unwrap();
5925 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
5926 let thread = agent.read_with(cx, |agent, _| {
5927 agent.sessions.get(&session_id).unwrap().thread.clone()
5928 });
5929 let model = Arc::new(FakeLanguageModel::default());
5930
5931 thread.update(cx, |thread, cx| {
5932 thread.set_model(model.clone(), cx);
5933 });
5934 cx.run_until_parked();
5935
5936 // === First turn: create subagent ===
5937 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("First prompt", cx));
5938 cx.run_until_parked();
5939 model.send_last_completion_stream_text_chunk("spawning subagent");
5940 let subagent_tool_input = SpawnAgentToolInput {
5941 label: "initial task".to_string(),
5942 message: "do the first task".to_string(),
5943 session_id: None,
5944 };
5945 let subagent_tool_use = LanguageModelToolUse {
5946 id: "subagent_1".into(),
5947 name: SpawnAgentTool::NAME.into(),
5948 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
5949 input: language_model::LanguageModelToolUseInput::Json(
5950 serde_json::to_value(&subagent_tool_input).unwrap(),
5951 ),
5952 is_input_complete: true,
5953 thought_signature: None,
5954 };
5955 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
5956 subagent_tool_use,
5957 ));
5958 model.end_last_completion_stream();
5959
5960 cx.run_until_parked();
5961
5962 let subagent_session_id = thread.read_with(cx, |thread, cx| {
5963 thread
5964 .running_subagent_ids(cx)
5965 .get(0)
5966 .expect("subagent thread should be running")
5967 .clone()
5968 });
5969
5970 let subagent_acp_thread = agent.read_with(cx, |agent, _cx| {
5971 agent
5972 .sessions
5973 .get(&subagent_session_id)
5974 .expect("subagent session should exist")
5975 .acp_thread
5976 .clone()
5977 });
5978
5979 // Subagent responds
5980 model.send_last_completion_stream_text_chunk("first task response");
5981 model.end_last_completion_stream();
5982
5983 cx.run_until_parked();
5984
5985 // Parent model responds to complete first turn
5986 model.send_last_completion_stream_text_chunk("First response");
5987 model.end_last_completion_stream();
5988
5989 send.await.unwrap();
5990
5991 // Verify subagent is no longer running
5992 thread.read_with(cx, |thread, cx| {
5993 assert!(
5994 thread.running_subagent_ids(cx).is_empty(),
5995 "subagent should not be running after completion"
5996 );
5997 });
5998
5999 // === Second turn: resume subagent with session_id ===
6000 let send2 = acp_thread.update(cx, |thread, cx| thread.send_raw("Follow up", cx));
6001 cx.run_until_parked();
6002 model.send_last_completion_stream_text_chunk("resuming subagent");
6003 let resume_tool_input = SpawnAgentToolInput {
6004 label: "follow-up task".to_string(),
6005 message: "do the follow-up task".to_string(),
6006 session_id: Some(subagent_session_id.clone()),
6007 };
6008 let resume_tool_use = LanguageModelToolUse {
6009 id: "subagent_2".into(),
6010 name: SpawnAgentTool::NAME.into(),
6011 raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
6012 input: language_model::LanguageModelToolUseInput::Json(
6013 serde_json::to_value(&resume_tool_input).unwrap(),
6014 ),
6015 is_input_complete: true,
6016 thought_signature: None,
6017 };
6018 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(resume_tool_use));
6019 model.end_last_completion_stream();
6020
6021 cx.run_until_parked();
6022
6023 // Subagent should be running again with the same session
6024 thread.read_with(cx, |thread, cx| {
6025 let running = thread.running_subagent_ids(cx);
6026 assert_eq!(running.len(), 1, "subagent should be running");
6027 assert_eq!(running[0], subagent_session_id, "should be same session");
6028 });
6029
6030 // Subagent responds to follow-up
6031 model.send_last_completion_stream_text_chunk("follow-up task response");
6032 model.end_last_completion_stream();
6033
6034 cx.run_until_parked();
6035
6036 // Parent model responds to complete second turn
6037 model.send_last_completion_stream_text_chunk("Second response");
6038 model.end_last_completion_stream();
6039
6040 send2.await.unwrap();
6041
6042 // Verify subagent is no longer running
6043 thread.read_with(cx, |thread, cx| {
6044 assert!(
6045 thread.running_subagent_ids(cx).is_empty(),
6046 "subagent should not be running after resume completion"
6047 );
6048 });
6049
6050 // Verify the subagent's acp thread has both conversation turns
6051 assert_eq!(
6052 subagent_acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx)),
6053 indoc! {"
6054 ## User
6055
6056 do the first task
6057
6058 ## Assistant
6059
6060 first task response
6061
6062 ## User
6063
6064 do the follow-up task
6065
6066 ## Assistant
6067
6068 follow-up task response
6069
6070 "}
6071 );
6072}
6073
6074#[gpui::test]
6075async fn test_subagent_thread_inherits_parent_thread_properties(cx: &mut TestAppContext) {
6076 init_test(cx);
6077
6078 cx.update(|cx| {
6079 cx.update_flags(true, vec!["subagents".to_string()]);
6080 });
6081
6082 let fs = FakeFs::new(cx.executor());
6083 fs.insert_tree(path!("/test"), json!({})).await;
6084 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6085 let project_context = cx.new(|_cx| ProjectContext::default());
6086 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6087 let context_server_registry =
6088 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6089 let model = Arc::new(FakeLanguageModel::default());
6090
6091 let parent_thread = cx.new(|cx| {
6092 Thread::new(
6093 project.clone(),
6094 project_context,
6095 context_server_registry,
6096 Templates::new(),
6097 Some(model.clone()),
6098 cx,
6099 )
6100 });
6101
6102 let subagent_thread = cx.new(|cx| Thread::new_subagent(&parent_thread, cx));
6103 subagent_thread.read_with(cx, |subagent_thread, cx| {
6104 assert!(subagent_thread.is_subagent());
6105 assert_eq!(subagent_thread.depth(), 1);
6106 assert_eq!(
6107 subagent_thread.model().map(|model| model.id()),
6108 Some(model.id())
6109 );
6110 assert_eq!(
6111 subagent_thread.parent_thread_id(),
6112 Some(parent_thread.read(cx).id().clone())
6113 );
6114
6115 let request = subagent_thread
6116 .build_completion_request(CompletionIntent::UserPrompt, cx)
6117 .unwrap();
6118 assert_eq!(request.intent, Some(CompletionIntent::Subagent));
6119 });
6120}
6121
6122#[gpui::test]
6123async fn test_subagent_thread_uses_configured_subagent_model(cx: &mut TestAppContext) {
6124 init_test(cx);
6125
6126 let fs = FakeFs::new(cx.executor());
6127 fs.insert_tree(path!("/test"), json!({})).await;
6128 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6129 let project_context = cx.new(|_cx| ProjectContext::default());
6130 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6131 let context_server_registry =
6132 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6133 let parent_model = Arc::new(FakeLanguageModel::default());
6134 let subagent_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
6135 "fake-corp",
6136 "subagent-model",
6137 "Subagent Model",
6138 true,
6139 ));
6140
6141 cx.update(|cx| {
6142 LanguageModelRegistry::test(cx);
6143
6144 let provider = Arc::new(
6145 FakeLanguageModelProvider::new(
6146 LanguageModelProviderId::from("fake-corp".to_string()),
6147 LanguageModelProviderName::from("Fake Corp".to_string()),
6148 )
6149 .with_models(vec![subagent_model.clone()]),
6150 );
6151 LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
6152 registry.register_provider(provider, cx);
6153 });
6154
6155 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
6156 settings.subagent_model = Some(LanguageModelSelection {
6157 provider: LanguageModelProviderSetting("fake-corp".to_string()),
6158 model: "subagent-model".to_string(),
6159 enable_thinking: true,
6160 effort: Some("high".to_string()),
6161 speed: None,
6162 });
6163 agent_settings::AgentSettings::override_global(settings, cx);
6164 });
6165
6166 let parent_thread = cx.new(|cx| {
6167 Thread::new(
6168 project.clone(),
6169 project_context,
6170 context_server_registry,
6171 Templates::new(),
6172 Some(parent_model.clone()),
6173 cx,
6174 )
6175 });
6176
6177 let subagent_thread = cx.new(|cx| Thread::new_subagent(&parent_thread, cx));
6178 subagent_thread.read_with(cx, |subagent_thread, _cx| {
6179 assert_eq!(
6180 subagent_thread.model().map(|model| model.id()),
6181 Some(subagent_model.id())
6182 );
6183 assert!(subagent_thread.thinking_enabled());
6184 assert_eq!(subagent_thread.thinking_effort(), Some(&"high".to_string()));
6185 });
6186
6187 parent_thread.update(cx, |parent_thread, _cx| {
6188 parent_thread.register_running_subagent(subagent_thread.downgrade());
6189 });
6190 parent_thread.update(cx, |parent_thread, cx| {
6191 parent_thread.set_model(parent_model.clone(), cx);
6192 parent_thread.set_thinking_enabled(false, cx);
6193 parent_thread.set_thinking_effort(None, cx);
6194 });
6195
6196 subagent_thread.read_with(cx, |subagent_thread, _cx| {
6197 assert_eq!(
6198 subagent_thread.model().map(|model| model.id()),
6199 Some(subagent_model.id())
6200 );
6201 assert!(subagent_thread.thinking_enabled());
6202 assert_eq!(subagent_thread.thinking_effort(), Some(&"high".to_string()));
6203 });
6204}
6205
6206#[gpui::test]
6207async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppContext) {
6208 init_test(cx);
6209
6210 cx.update(|cx| {
6211 cx.update_flags(true, vec!["subagents".to_string()]);
6212 });
6213
6214 let fs = FakeFs::new(cx.executor());
6215 fs.insert_tree(path!("/test"), json!({})).await;
6216 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6217 let project_context = cx.new(|_cx| ProjectContext::default());
6218 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6219 let context_server_registry =
6220 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6221 let model = Arc::new(FakeLanguageModel::default());
6222 let environment = Rc::new(cx.update(|cx| {
6223 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
6224 }));
6225
6226 let deep_parent_thread = cx.new(|cx| {
6227 let mut thread = Thread::new(
6228 project.clone(),
6229 project_context,
6230 context_server_registry,
6231 Templates::new(),
6232 Some(model.clone()),
6233 cx,
6234 );
6235 thread.set_subagent_context(SubagentContext {
6236 parent_thread_id: acp::SessionId::new("parent-id"),
6237 depth: MAX_SUBAGENT_DEPTH - 1,
6238 });
6239 thread
6240 });
6241 let deep_subagent_thread = cx.new(|cx| {
6242 let mut thread = Thread::new_subagent(&deep_parent_thread, cx);
6243 thread.add_default_tools(environment, cx);
6244 thread
6245 });
6246
6247 deep_subagent_thread.read_with(cx, |thread, _| {
6248 assert_eq!(thread.depth(), MAX_SUBAGENT_DEPTH);
6249 assert!(
6250 !thread.has_registered_tool(SpawnAgentTool::NAME),
6251 "subagent tool should not be present at max depth"
6252 );
6253 });
6254}
6255
6256#[gpui::test]
6257async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
6258 init_test(cx);
6259
6260 let fs = FakeFs::new(cx.executor());
6261 fs.insert_tree(path!("/test"), json!({})).await;
6262 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6263 let project_context = cx.new(|_cx| ProjectContext::default());
6264 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6265 let context_server_registry =
6266 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6267 let model = Arc::new(FakeLanguageModel::default());
6268 let environment = Rc::new(cx.update(|cx| {
6269 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
6270 }));
6271
6272 let thread = cx.new(|cx| {
6273 let mut thread = Thread::new(
6274 project,
6275 project_context,
6276 context_server_registry,
6277 Templates::new(),
6278 Some(model.clone() as Arc<dyn LanguageModel>),
6279 cx,
6280 );
6281 thread.add_default_tools(environment, cx);
6282 thread
6283 });
6284
6285 let lsp_tool_names = [
6286 FindReferencesTool::NAME,
6287 GetCodeActionsTool::NAME,
6288 ApplyCodeActionTool::NAME,
6289 GoToDefinitionTool::NAME,
6290 ];
6291
6292 // All LSP tools and the rename tool should be registered on the thread
6293 // regardless of the flag, since the feature flags only control exposure
6294 // to the model rather than registration.
6295 thread.read_with(cx, |thread, _| {
6296 for name in &lsp_tool_names {
6297 assert!(
6298 thread.has_registered_tool(name),
6299 "expected LSP tool {name} to be registered"
6300 );
6301 }
6302 assert!(
6303 thread.has_registered_tool(RenameTool::NAME),
6304 "expected rename tool to be registered"
6305 );
6306 });
6307
6308 // Without the `lsp-tool` flag, sending a message should produce a
6309 // completion request whose tool list excludes the LSP tools.
6310 // The rename tool is on its own `rename-tool` flag with
6311 // `enabled_for_staff`, so it is already visible in debug builds.
6312 thread
6313 .update(cx, |thread, cx| {
6314 thread.send(ClientUserMessageId::new(), ["hello"], cx)
6315 })
6316 .unwrap();
6317 cx.run_until_parked();
6318
6319 let completion = model.pending_completions().pop().unwrap();
6320 let tool_names = tool_names_for_completion(&completion);
6321 for name in &lsp_tool_names {
6322 assert!(
6323 !tool_names.iter().any(|t| t == name),
6324 "expected LSP tool {name} to be hidden without the lsp-tool flag, \
6325 but completion tools were: {tool_names:?}"
6326 );
6327 }
6328 assert!(
6329 tool_names.iter().any(|t| t == RenameTool::NAME),
6330 "expected rename tool to be visible (enabled_for_staff in debug builds), \
6331 but completion tools were: {tool_names:?}"
6332 );
6333 // Sanity check: a non-LSP default tool should still be exposed.
6334 assert!(
6335 tool_names.iter().any(|t| t == ReadFileTool::NAME),
6336 "expected non-LSP tools to still be exposed, got: {tool_names:?}"
6337 );
6338 model.end_last_completion_stream();
6339 cx.run_until_parked();
6340
6341 // Enable the `lsp-tool` flag and send another message; the LSP tools
6342 // should now appear in the completion request.
6343 cx.update(|cx| {
6344 cx.update_flags(false, vec!["lsp-tool".to_string()]);
6345 });
6346
6347 thread
6348 .update(cx, |thread, cx| {
6349 thread.send(ClientUserMessageId::new(), ["hello again"], cx)
6350 })
6351 .unwrap();
6352 cx.run_until_parked();
6353
6354 let completion = model.pending_completions().pop().unwrap();
6355 let tool_names = tool_names_for_completion(&completion);
6356 for name in &lsp_tool_names {
6357 assert!(
6358 tool_names.iter().any(|t| t == name),
6359 "expected LSP tool {name} to be exposed when lsp-tool flag is on, \
6360 but completion tools were: {tool_names:?}"
6361 );
6362 }
6363 assert!(
6364 tool_names.iter().any(|t| t == RenameTool::NAME),
6365 "expected rename tool to still be exposed, \
6366 but completion tools were: {tool_names:?}"
6367 );
6368}
6369
6370#[gpui::test]
6371async fn test_sibling_thread_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
6372 init_test(cx);
6373
6374 // `CreateThreadToolFeatureFlag::enabled_for_staff()` returns true, which
6375 // means tests in debug builds resolve it to ON unless we explicitly
6376 // override it via `FeatureFlagsSettings`. Register the settings type and
6377 // install an (empty) `FeatureFlagStore` global so the `cx.has_flag` path
6378 // actually consults overrides instead of falling back to the
6379 // staff-debug-build default.
6380 cx.update(|cx| {
6381 SettingsStore::update_global(cx, |store, _| {
6382 store.register_setting::<feature_flags::FeatureFlagsSettings>();
6383 });
6384 cx.update_flags(false, vec![]);
6385 });
6386
6387 fn set_flag_override(value: &str, cx: &mut TestAppContext) {
6388 cx.update(|cx| {
6389 SettingsStore::update_global(cx, |store, cx| {
6390 store.update_user_settings(cx, |content| {
6391 content
6392 .feature_flags
6393 .get_or_insert_default()
6394 .insert("create-thread-tool".to_string(), value.to_string());
6395 });
6396 });
6397 });
6398 }
6399
6400 let fs = FakeFs::new(cx.executor());
6401 fs.insert_tree(path!("/test"), json!({})).await;
6402 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6403 let project_context = cx.new(|_cx| ProjectContext::default());
6404 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6405 let context_server_registry =
6406 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6407 let model = Arc::new(FakeLanguageModel::default());
6408 let environment = Rc::new(cx.update(|cx| {
6409 FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
6410 }));
6411
6412 let thread = cx.new(|cx| {
6413 let mut thread = Thread::new(
6414 project,
6415 project_context,
6416 context_server_registry,
6417 Templates::new(),
6418 Some(model.clone() as Arc<dyn LanguageModel>),
6419 cx,
6420 );
6421 thread.add_default_tools(environment, cx);
6422 thread
6423 });
6424
6425 let sibling_tool_names = [CreateThreadTool::NAME, ListAgentsAndModelsTool::NAME];
6426
6427 // Like the LSP/rename tools, sibling-thread tools are registered
6428 // unconditionally and gated only at exposure time. The registration must
6429 // be visible regardless of the flag's current value.
6430 thread.read_with(cx, |thread, _| {
6431 for name in &sibling_tool_names {
6432 assert!(
6433 thread.has_registered_tool(name),
6434 "expected sibling-thread tool {name} to be registered"
6435 );
6436 }
6437 });
6438
6439 // Flag explicitly off: a completion request must omit the tools.
6440 set_flag_override("off", cx);
6441 thread
6442 .update(cx, |thread, cx| {
6443 thread.send(ClientUserMessageId::new(), ["hello"], cx)
6444 })
6445 .unwrap();
6446 cx.run_until_parked();
6447
6448 let completion = model.pending_completions().pop().unwrap();
6449 let tool_names = tool_names_for_completion(&completion);
6450 for name in &sibling_tool_names {
6451 assert!(
6452 !tool_names.iter().any(|t| t == name),
6453 "expected {name} to be hidden when create-thread-tool flag is off, \
6454 but completion tools were: {tool_names:?}"
6455 );
6456 }
6457 // Sanity check: an unrelated default tool should still be exposed.
6458 assert!(
6459 tool_names.iter().any(|t| t == ReadFileTool::NAME),
6460 "expected non-sibling-thread tools to still be exposed, got: {tool_names:?}"
6461 );
6462 model.end_last_completion_stream();
6463 cx.run_until_parked();
6464
6465 // Flag explicitly on: the next completion request must include both tools.
6466 set_flag_override("on", cx);
6467 thread
6468 .update(cx, |thread, cx| {
6469 thread.send(ClientUserMessageId::new(), ["hello again"], cx)
6470 })
6471 .unwrap();
6472 cx.run_until_parked();
6473
6474 let completion = model.pending_completions().pop().unwrap();
6475 let tool_names = tool_names_for_completion(&completion);
6476 for name in &sibling_tool_names {
6477 assert!(
6478 tool_names.iter().any(|t| t == name),
6479 "expected {name} to be exposed when create-thread-tool flag is on, \
6480 but completion tools were: {tool_names:?}"
6481 );
6482 }
6483}
6484
6485#[gpui::test]
6486async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
6487 init_test(cx);
6488
6489 cx.update(|cx| {
6490 cx.update_flags(true, vec!["subagents".to_string()]);
6491 });
6492
6493 let fs = FakeFs::new(cx.executor());
6494 fs.insert_tree(path!("/test"), json!({})).await;
6495 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6496 let project_context = cx.new(|_cx| ProjectContext::default());
6497 let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
6498 let context_server_registry =
6499 cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
6500 let model = Arc::new(FakeLanguageModel::default());
6501
6502 let parent = cx.new(|cx| {
6503 Thread::new(
6504 project.clone(),
6505 project_context.clone(),
6506 context_server_registry.clone(),
6507 Templates::new(),
6508 Some(model.clone()),
6509 cx,
6510 )
6511 });
6512
6513 let subagent = cx.new(|cx| Thread::new_subagent(&parent, cx));
6514
6515 parent.update(cx, |thread, _cx| {
6516 thread.register_running_subagent(subagent.downgrade());
6517 });
6518
6519 subagent
6520 .update(cx, |thread, cx| {
6521 thread.send(ClientUserMessageId::new(), ["Do work".to_string()], cx)
6522 })
6523 .unwrap();
6524 cx.run_until_parked();
6525
6526 subagent.read_with(cx, |thread, _| {
6527 assert!(!thread.is_turn_complete(), "subagent should be running");
6528 });
6529
6530 parent.update(cx, |thread, cx| {
6531 thread.cancel(cx).detach();
6532 });
6533
6534 subagent.read_with(cx, |thread, _| {
6535 assert!(
6536 thread.is_turn_complete(),
6537 "subagent should be cancelled when parent cancels"
6538 );
6539 });
6540}
6541
6542#[gpui::test]
6543async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
6544 init_test(cx);
6545 cx.update(|cx| {
6546 LanguageModelRegistry::test(cx);
6547 });
6548 cx.update(|cx| {
6549 cx.update_flags(true, vec!["subagents".to_string()]);
6550 });
6551
6552 let fs = FakeFs::new(cx.executor());
6553 fs.insert_tree(
6554 "/",
6555 json!({
6556 "a": {
6557 "b.md": "Lorem"
6558 }
6559 }),
6560 )
6561 .await;
6562 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6563 let thread_store = cx.new(|cx| ThreadStore::new(cx));
6564 let agent =
6565 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6566 let connection = Rc::new(NativeAgentConnection(agent.clone()));
6567
6568 let acp_thread = cx
6569 .update(|cx| {
6570 connection
6571 .clone()
6572 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6573 })
6574 .await
6575 .unwrap();
6576 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6577 let thread = agent.read_with(cx, |agent, _| {
6578 agent.sessions.get(&session_id).unwrap().thread.clone()
6579 });
6580 let model = Arc::new(FakeLanguageModel::default());
6581
6582 thread.update(cx, |thread, cx| {
6583 thread.set_model(model.clone(), cx);
6584 });
6585 cx.run_until_parked();
6586
6587 // Start the parent turn
6588 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx));
6589 cx.run_until_parked();
6590 model.send_last_completion_stream_text_chunk("spawning subagent");
6591 let subagent_tool_input = SpawnAgentToolInput {
6592 label: "label".to_string(),
6593 message: "subagent task prompt".to_string(),
6594 session_id: None,
6595 };
6596 let subagent_tool_use = LanguageModelToolUse {
6597 id: "subagent_1".into(),
6598 name: SpawnAgentTool::NAME.into(),
6599 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
6600 input: language_model::LanguageModelToolUseInput::Json(
6601 serde_json::to_value(&subagent_tool_input).unwrap(),
6602 ),
6603 is_input_complete: true,
6604 thought_signature: None,
6605 };
6606 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
6607 subagent_tool_use,
6608 ));
6609 model.end_last_completion_stream();
6610
6611 cx.run_until_parked();
6612
6613 // Verify subagent is running
6614 let subagent_session_id = thread.read_with(cx, |thread, cx| {
6615 thread
6616 .running_subagent_ids(cx)
6617 .get(0)
6618 .expect("subagent thread should be running")
6619 .clone()
6620 });
6621
6622 // Send a usage update that crosses the warning threshold (80% of 1,000,000)
6623 model.send_last_completion_stream_text_chunk("partial work");
6624 model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
6625 TokenUsage {
6626 input_tokens: 850_000,
6627 output_tokens: 0,
6628 cache_creation_input_tokens: 0,
6629 cache_read_input_tokens: 0,
6630 },
6631 ));
6632
6633 cx.run_until_parked();
6634
6635 // The subagent should no longer be running
6636 thread.read_with(cx, |thread, cx| {
6637 assert!(
6638 thread.running_subagent_ids(cx).is_empty(),
6639 "subagent should be stopped after context window warning"
6640 );
6641 });
6642
6643 // The parent model should get a new completion request to respond to the tool error
6644 model.send_last_completion_stream_text_chunk("Response after warning");
6645 model.end_last_completion_stream();
6646
6647 send.await.unwrap();
6648
6649 // Verify the parent thread shows the warning error in the tool call
6650 let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
6651 assert!(
6652 markdown.contains("nearing the end of its context window"),
6653 "tool output should contain context window warning message, got:\n{markdown}"
6654 );
6655 assert!(
6656 markdown.contains("Status: Failed"),
6657 "tool call should have Failed status, got:\n{markdown}"
6658 );
6659
6660 // Verify the subagent session still exists (can be resumed)
6661 agent.read_with(cx, |agent, _cx| {
6662 assert!(
6663 agent.sessions.contains_key(&subagent_session_id),
6664 "subagent session should still exist for potential resume"
6665 );
6666 });
6667}
6668
6669#[gpui::test]
6670async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mut TestAppContext) {
6671 init_test(cx);
6672 cx.update(|cx| {
6673 LanguageModelRegistry::test(cx);
6674 });
6675 cx.update(|cx| {
6676 cx.update_flags(true, vec!["subagents".to_string()]);
6677 });
6678
6679 let fs = FakeFs::new(cx.executor());
6680 fs.insert_tree(
6681 "/",
6682 json!({
6683 "a": {
6684 "b.md": "Lorem"
6685 }
6686 }),
6687 )
6688 .await;
6689 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6690 let thread_store = cx.new(|cx| ThreadStore::new(cx));
6691 let agent =
6692 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6693 let connection = Rc::new(NativeAgentConnection(agent.clone()));
6694
6695 let acp_thread = cx
6696 .update(|cx| {
6697 connection
6698 .clone()
6699 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6700 })
6701 .await
6702 .unwrap();
6703 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6704 let thread = agent.read_with(cx, |agent, _| {
6705 agent.sessions.get(&session_id).unwrap().thread.clone()
6706 });
6707 let model = Arc::new(FakeLanguageModel::default());
6708
6709 thread.update(cx, |thread, cx| {
6710 thread.set_model(model.clone(), cx);
6711 });
6712 cx.run_until_parked();
6713
6714 // === First turn: create subagent, trigger context window warning ===
6715 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("First prompt", cx));
6716 cx.run_until_parked();
6717 model.send_last_completion_stream_text_chunk("spawning subagent");
6718 let subagent_tool_input = SpawnAgentToolInput {
6719 label: "initial task".to_string(),
6720 message: "do the first task".to_string(),
6721 session_id: None,
6722 };
6723 let subagent_tool_use = LanguageModelToolUse {
6724 id: "subagent_1".into(),
6725 name: SpawnAgentTool::NAME.into(),
6726 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
6727 input: language_model::LanguageModelToolUseInput::Json(
6728 serde_json::to_value(&subagent_tool_input).unwrap(),
6729 ),
6730 is_input_complete: true,
6731 thought_signature: None,
6732 };
6733 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
6734 subagent_tool_use,
6735 ));
6736 model.end_last_completion_stream();
6737
6738 cx.run_until_parked();
6739
6740 let subagent_session_id = thread.read_with(cx, |thread, cx| {
6741 thread
6742 .running_subagent_ids(cx)
6743 .get(0)
6744 .expect("subagent thread should be running")
6745 .clone()
6746 });
6747
6748 // Subagent sends a usage update that crosses the warning threshold.
6749 // This triggers Normal→Warning, stopping the subagent.
6750 model.send_last_completion_stream_text_chunk("partial work");
6751 model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
6752 TokenUsage {
6753 input_tokens: 850_000,
6754 output_tokens: 0,
6755 cache_creation_input_tokens: 0,
6756 cache_read_input_tokens: 0,
6757 },
6758 ));
6759
6760 cx.run_until_parked();
6761
6762 // Verify the first turn was stopped with a context window warning
6763 thread.read_with(cx, |thread, cx| {
6764 assert!(
6765 thread.running_subagent_ids(cx).is_empty(),
6766 "subagent should be stopped after context window warning"
6767 );
6768 });
6769
6770 // Parent model responds to complete first turn
6771 model.send_last_completion_stream_text_chunk("First response");
6772 model.end_last_completion_stream();
6773
6774 send.await.unwrap();
6775
6776 let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
6777 assert!(
6778 markdown.contains("nearing the end of its context window"),
6779 "first turn should have context window warning, got:\n{markdown}"
6780 );
6781
6782 // === Second turn: resume the same subagent (now at Warning level) ===
6783 let send2 = acp_thread.update(cx, |thread, cx| thread.send_raw("Follow up", cx));
6784 cx.run_until_parked();
6785 model.send_last_completion_stream_text_chunk("resuming subagent");
6786 let resume_tool_input = SpawnAgentToolInput {
6787 label: "follow-up task".to_string(),
6788 message: "do the follow-up task".to_string(),
6789 session_id: Some(subagent_session_id.clone()),
6790 };
6791 let resume_tool_use = LanguageModelToolUse {
6792 id: "subagent_2".into(),
6793 name: SpawnAgentTool::NAME.into(),
6794 raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
6795 input: language_model::LanguageModelToolUseInput::Json(
6796 serde_json::to_value(&resume_tool_input).unwrap(),
6797 ),
6798 is_input_complete: true,
6799 thought_signature: None,
6800 };
6801 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(resume_tool_use));
6802 model.end_last_completion_stream();
6803
6804 cx.run_until_parked();
6805
6806 // Subagent responds with tokens still at warning level (no worse).
6807 // Since ratio_before_prompt was already Warning, this should NOT
6808 // trigger the context window warning again.
6809 model.send_last_completion_stream_text_chunk("follow-up task response");
6810 model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
6811 TokenUsage {
6812 input_tokens: 870_000,
6813 output_tokens: 0,
6814 cache_creation_input_tokens: 0,
6815 cache_read_input_tokens: 0,
6816 },
6817 ));
6818 model.end_last_completion_stream();
6819
6820 cx.run_until_parked();
6821
6822 // Parent model responds to complete second turn
6823 model.send_last_completion_stream_text_chunk("Second response");
6824 model.end_last_completion_stream();
6825
6826 send2.await.unwrap();
6827
6828 // The resumed subagent should have completed normally since the ratio
6829 // didn't transition (it was Warning before and stayed at Warning)
6830 let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
6831 assert!(
6832 markdown.contains("follow-up task response"),
6833 "resumed subagent should complete normally when already at warning, got:\n{markdown}"
6834 );
6835 // The second tool call should NOT have a context window warning
6836 let second_tool_pos = markdown
6837 .find("follow-up task")
6838 .expect("should find follow-up tool call");
6839 let after_second_tool = &markdown[second_tool_pos..];
6840 assert!(
6841 !after_second_tool.contains("nearing the end of its context window"),
6842 "should NOT contain context window warning for resumed subagent at same level, got:\n{after_second_tool}"
6843 );
6844}
6845
6846#[gpui::test]
6847async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
6848 init_test(cx);
6849 cx.update(|cx| {
6850 LanguageModelRegistry::test(cx);
6851 });
6852 cx.update(|cx| {
6853 cx.update_flags(true, vec!["subagents".to_string()]);
6854 });
6855
6856 let fs = FakeFs::new(cx.executor());
6857 fs.insert_tree(
6858 "/",
6859 json!({
6860 "a": {
6861 "b.md": "Lorem"
6862 }
6863 }),
6864 )
6865 .await;
6866 let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
6867 let thread_store = cx.new(|cx| ThreadStore::new(cx));
6868 let agent =
6869 cx.update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx));
6870 let connection = Rc::new(NativeAgentConnection(agent.clone()));
6871
6872 let acp_thread = cx
6873 .update(|cx| {
6874 connection
6875 .clone()
6876 .new_session(project.clone(), PathList::new(&[Path::new("")]), cx)
6877 })
6878 .await
6879 .unwrap();
6880 let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
6881 let thread = agent.read_with(cx, |agent, _| {
6882 agent.sessions.get(&session_id).unwrap().thread.clone()
6883 });
6884 let model = Arc::new(FakeLanguageModel::default());
6885
6886 thread.update(cx, |thread, cx| {
6887 thread.set_model(model.clone(), cx);
6888 });
6889 cx.run_until_parked();
6890
6891 // Start the parent turn
6892 let send = acp_thread.update(cx, |thread, cx| thread.send_raw("Prompt", cx));
6893 cx.run_until_parked();
6894 model.send_last_completion_stream_text_chunk("spawning subagent");
6895 let subagent_tool_input = SpawnAgentToolInput {
6896 label: "label".to_string(),
6897 message: "subagent task prompt".to_string(),
6898 session_id: None,
6899 };
6900 let subagent_tool_use = LanguageModelToolUse {
6901 id: "subagent_1".into(),
6902 name: SpawnAgentTool::NAME.into(),
6903 raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
6904 input: language_model::LanguageModelToolUseInput::Json(
6905 serde_json::to_value(&subagent_tool_input).unwrap(),
6906 ),
6907 is_input_complete: true,
6908 thought_signature: None,
6909 };
6910 model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
6911 subagent_tool_use,
6912 ));
6913 model.end_last_completion_stream();
6914
6915 cx.run_until_parked();
6916
6917 // Verify subagent is running
6918 thread.read_with(cx, |thread, cx| {
6919 assert!(
6920 !thread.running_subagent_ids(cx).is_empty(),
6921 "subagent should be running"
6922 );
6923 });
6924
6925 // The subagent's model returns a non-retryable error
6926 model.send_last_completion_stream_error(LanguageModelCompletionError::PromptTooLarge {
6927 tokens: None,
6928 });
6929
6930 cx.run_until_parked();
6931
6932 // The subagent should no longer be running
6933 thread.read_with(cx, |thread, cx| {
6934 assert!(
6935 thread.running_subagent_ids(cx).is_empty(),
6936 "subagent should not be running after error"
6937 );
6938 });
6939
6940 // The parent model should get a new completion request to respond to the tool error
6941 model.send_last_completion_stream_text_chunk("Response after error");
6942 model.end_last_completion_stream();
6943
6944 send.await.unwrap();
6945
6946 // Verify the parent thread shows the error in the tool call
6947 let markdown = acp_thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
6948 assert!(
6949 markdown.contains("Status: Failed"),
6950 "tool call should have Failed status after model error, got:\n{markdown}"
6951 );
6952}
6953
6954#[gpui::test]
6955async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
6956 init_test(cx);
6957
6958 let fs = FakeFs::new(cx.executor());
6959 fs.insert_tree("/root", json!({"sensitive_config.txt": "secret data"}))
6960 .await;
6961 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6962
6963 cx.update(|cx| {
6964 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
6965 settings.tool_permissions.tools.insert(
6966 EditFileTool::NAME.into(),
6967 agent_settings::ToolRules {
6968 default: Some(settings::ToolPermissionMode::Allow),
6969 always_allow: vec![],
6970 always_deny: vec![agent_settings::CompiledRegex::new(r"sensitive", false).unwrap()],
6971 always_confirm: vec![],
6972 invalid_patterns: vec![],
6973 },
6974 );
6975 agent_settings::AgentSettings::override_global(settings, cx);
6976 });
6977
6978 let context_server_registry =
6979 cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
6980 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
6981 let templates = crate::Templates::new();
6982 let thread = cx.new(|cx| {
6983 crate::Thread::new(
6984 project.clone(),
6985 cx.new(|_cx| prompt_store::ProjectContext::default()),
6986 context_server_registry,
6987 templates.clone(),
6988 None,
6989 cx,
6990 )
6991 });
6992 let action_log = cx.update(|cx| thread.read(cx).action_log.clone());
6993
6994 #[allow(clippy::arc_with_non_send_sync)]
6995 let tool = Arc::new(crate::EditFileTool::new(
6996 project.clone(),
6997 thread.downgrade(),
6998 action_log,
6999 language_registry,
7000 ));
7001 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7002
7003 let task = cx.update(|cx| {
7004 tool.run(
7005 ToolInput::resolved(crate::EditFileToolInput {
7006 path: "root/sensitive_config.txt".into(),
7007 edits: vec![],
7008 }),
7009 event_stream,
7010 cx,
7011 )
7012 });
7013
7014 let result = task.await;
7015 assert!(result.is_err(), "expected edit to be blocked");
7016 assert!(
7017 result.unwrap_err().to_string().contains("blocked"),
7018 "error should mention the edit was blocked"
7019 );
7020}
7021
7022#[gpui::test]
7023async fn test_delete_path_tool_deny_rule_blocks_deletion(cx: &mut TestAppContext) {
7024 init_test(cx);
7025
7026 let fs = FakeFs::new(cx.executor());
7027 fs.insert_tree("/root", json!({"important_data.txt": "critical info"}))
7028 .await;
7029 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7030
7031 cx.update(|cx| {
7032 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7033 settings.tool_permissions.tools.insert(
7034 DeletePathTool::NAME.into(),
7035 agent_settings::ToolRules {
7036 default: Some(settings::ToolPermissionMode::Allow),
7037 always_allow: vec![],
7038 always_deny: vec![agent_settings::CompiledRegex::new(r"important", false).unwrap()],
7039 always_confirm: vec![],
7040 invalid_patterns: vec![],
7041 },
7042 );
7043 agent_settings::AgentSettings::override_global(settings, cx);
7044 });
7045
7046 let action_log = cx.new(|_cx| action_log::ActionLog::new(project.clone()));
7047
7048 #[allow(clippy::arc_with_non_send_sync)]
7049 let tool = Arc::new(crate::DeletePathTool::new(project, action_log));
7050 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7051
7052 let task = cx.update(|cx| {
7053 tool.run(
7054 ToolInput::resolved(crate::DeletePathToolInput {
7055 path: "root/important_data.txt".to_string(),
7056 }),
7057 event_stream,
7058 cx,
7059 )
7060 });
7061
7062 let result = task.await;
7063 assert!(result.is_err(), "expected deletion to be blocked");
7064 assert!(
7065 result.unwrap_err().contains("blocked"),
7066 "error should mention the deletion was blocked"
7067 );
7068}
7069
7070#[gpui::test]
7071async fn test_move_path_tool_denies_if_destination_denied(cx: &mut TestAppContext) {
7072 init_test(cx);
7073
7074 let fs = FakeFs::new(cx.executor());
7075 fs.insert_tree(
7076 "/root",
7077 json!({
7078 "safe.txt": "content",
7079 "protected": {}
7080 }),
7081 )
7082 .await;
7083 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7084
7085 cx.update(|cx| {
7086 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7087 settings.tool_permissions.tools.insert(
7088 MovePathTool::NAME.into(),
7089 agent_settings::ToolRules {
7090 default: Some(settings::ToolPermissionMode::Allow),
7091 always_allow: vec![],
7092 always_deny: vec![agent_settings::CompiledRegex::new(r"protected", false).unwrap()],
7093 always_confirm: vec![],
7094 invalid_patterns: vec![],
7095 },
7096 );
7097 agent_settings::AgentSettings::override_global(settings, cx);
7098 });
7099
7100 #[allow(clippy::arc_with_non_send_sync)]
7101 let tool = Arc::new(crate::MovePathTool::new(project));
7102 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7103
7104 let task = cx.update(|cx| {
7105 tool.run(
7106 ToolInput::resolved(crate::MovePathToolInput {
7107 source_path: "root/safe.txt".to_string(),
7108 destination_path: "root/protected/safe.txt".to_string(),
7109 }),
7110 event_stream,
7111 cx,
7112 )
7113 });
7114
7115 let result = task.await;
7116 assert!(
7117 result.is_err(),
7118 "expected move to be blocked due to destination path"
7119 );
7120 assert!(
7121 result.unwrap_err().contains("blocked"),
7122 "error should mention the move was blocked"
7123 );
7124}
7125
7126#[gpui::test]
7127async fn test_move_path_tool_denies_if_source_denied(cx: &mut TestAppContext) {
7128 init_test(cx);
7129
7130 let fs = FakeFs::new(cx.executor());
7131 fs.insert_tree(
7132 "/root",
7133 json!({
7134 "secret.txt": "secret content",
7135 "public": {}
7136 }),
7137 )
7138 .await;
7139 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7140
7141 cx.update(|cx| {
7142 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7143 settings.tool_permissions.tools.insert(
7144 MovePathTool::NAME.into(),
7145 agent_settings::ToolRules {
7146 default: Some(settings::ToolPermissionMode::Allow),
7147 always_allow: vec![],
7148 always_deny: vec![agent_settings::CompiledRegex::new(r"secret", false).unwrap()],
7149 always_confirm: vec![],
7150 invalid_patterns: vec![],
7151 },
7152 );
7153 agent_settings::AgentSettings::override_global(settings, cx);
7154 });
7155
7156 #[allow(clippy::arc_with_non_send_sync)]
7157 let tool = Arc::new(crate::MovePathTool::new(project));
7158 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7159
7160 let task = cx.update(|cx| {
7161 tool.run(
7162 ToolInput::resolved(crate::MovePathToolInput {
7163 source_path: "root/secret.txt".to_string(),
7164 destination_path: "root/public/not_secret.txt".to_string(),
7165 }),
7166 event_stream,
7167 cx,
7168 )
7169 });
7170
7171 let result = task.await;
7172 assert!(
7173 result.is_err(),
7174 "expected move to be blocked due to source path"
7175 );
7176 assert!(
7177 result.unwrap_err().contains("blocked"),
7178 "error should mention the move was blocked"
7179 );
7180}
7181
7182#[gpui::test]
7183async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
7184 init_test(cx);
7185
7186 let fs = FakeFs::new(cx.executor());
7187 fs.insert_tree(
7188 "/root",
7189 json!({
7190 "confidential.txt": "confidential data",
7191 "dest": {}
7192 }),
7193 )
7194 .await;
7195 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7196
7197 cx.update(|cx| {
7198 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7199 settings.tool_permissions.tools.insert(
7200 CopyPathTool::NAME.into(),
7201 agent_settings::ToolRules {
7202 default: Some(settings::ToolPermissionMode::Allow),
7203 always_allow: vec![],
7204 always_deny: vec![
7205 agent_settings::CompiledRegex::new(r"confidential", false).unwrap(),
7206 ],
7207 always_confirm: vec![],
7208 invalid_patterns: vec![],
7209 },
7210 );
7211 agent_settings::AgentSettings::override_global(settings, cx);
7212 });
7213
7214 #[allow(clippy::arc_with_non_send_sync)]
7215 let tool = Arc::new(crate::CopyPathTool::new(project));
7216 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7217
7218 let task = cx.update(|cx| {
7219 tool.run(
7220 ToolInput::resolved(crate::CopyPathToolInput {
7221 source_path: "root/confidential.txt".to_string(),
7222 destination_path: "root/dest/copy.txt".to_string(),
7223 }),
7224 event_stream,
7225 cx,
7226 )
7227 });
7228
7229 let result = task.await;
7230 assert!(result.is_err(), "expected copy to be blocked");
7231 assert!(
7232 result.unwrap_err().contains("blocked"),
7233 "error should mention the copy was blocked"
7234 );
7235}
7236
7237#[gpui::test]
7238async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
7239 init_test(cx);
7240
7241 cx.update(|cx| {
7242 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7243 settings.tool_permissions.tools.insert(
7244 WebSearchTool::NAME.into(),
7245 agent_settings::ToolRules {
7246 default: Some(settings::ToolPermissionMode::Allow),
7247 always_allow: vec![],
7248 always_deny: vec![
7249 agent_settings::CompiledRegex::new(r"internal\.company", false).unwrap(),
7250 ],
7251 always_confirm: vec![],
7252 invalid_patterns: vec![],
7253 },
7254 );
7255 agent_settings::AgentSettings::override_global(settings, cx);
7256 });
7257
7258 #[allow(clippy::arc_with_non_send_sync)]
7259 let tool = Arc::new(crate::WebSearchTool);
7260 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7261
7262 let input: crate::WebSearchToolInput =
7263 serde_json::from_value(json!({"query": "internal.company.com secrets"})).unwrap();
7264
7265 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7266
7267 let result = task.await;
7268 assert!(result.is_err(), "expected search to be blocked");
7269 match result.unwrap_err() {
7270 crate::WebSearchToolOutput::Error { error } => {
7271 assert!(
7272 error.contains("blocked"),
7273 "error should mention the search was blocked"
7274 );
7275 }
7276 other => panic!("expected Error variant, got: {other:?}"),
7277 }
7278}
7279
7280#[gpui::test]
7281async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) {
7282 init_test(cx);
7283
7284 let fs = FakeFs::new(cx.executor());
7285 fs.insert_tree("/root", json!({"README.md": "# Hello"}))
7286 .await;
7287 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7288
7289 cx.update(|cx| {
7290 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7291 settings.tool_permissions.tools.insert(
7292 EditFileTool::NAME.into(),
7293 agent_settings::ToolRules {
7294 default: Some(settings::ToolPermissionMode::Confirm),
7295 always_allow: vec![agent_settings::CompiledRegex::new(r"\.md$", false).unwrap()],
7296 always_deny: vec![],
7297 always_confirm: vec![],
7298 invalid_patterns: vec![],
7299 },
7300 );
7301 agent_settings::AgentSettings::override_global(settings, cx);
7302 });
7303
7304 let context_server_registry =
7305 cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
7306 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
7307 let templates = crate::Templates::new();
7308 let thread = cx.new(|cx| {
7309 crate::Thread::new(
7310 project.clone(),
7311 cx.new(|_cx| prompt_store::ProjectContext::default()),
7312 context_server_registry,
7313 templates.clone(),
7314 None,
7315 cx,
7316 )
7317 });
7318 let action_log = thread.read_with(cx, |thread, _cx| thread.action_log().clone());
7319
7320 #[allow(clippy::arc_with_non_send_sync)]
7321 let tool = Arc::new(crate::EditFileTool::new(
7322 project,
7323 thread.downgrade(),
7324 action_log,
7325 language_registry,
7326 ));
7327 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7328
7329 let _task = cx.update(|cx| {
7330 tool.run(
7331 ToolInput::resolved(crate::EditFileToolInput {
7332 path: "root/README.md".into(),
7333 edits: vec![],
7334 }),
7335 event_stream,
7336 cx,
7337 )
7338 });
7339
7340 cx.run_until_parked();
7341
7342 let event = rx.try_recv();
7343 assert!(
7344 !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
7345 "expected no authorization request for allowed .md file"
7346 );
7347}
7348
7349#[gpui::test]
7350async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut TestAppContext) {
7351 init_test(cx);
7352
7353 let fs = FakeFs::new(cx.executor());
7354 fs.insert_tree(
7355 "/root",
7356 json!({
7357 ".zed": {
7358 "settings.json": "{}"
7359 },
7360 "README.md": "# Hello"
7361 }),
7362 )
7363 .await;
7364 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7365
7366 cx.update(|cx| {
7367 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7368 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
7369 agent_settings::AgentSettings::override_global(settings, cx);
7370 });
7371
7372 let context_server_registry =
7373 cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
7374 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
7375 let templates = crate::Templates::new();
7376 let thread = cx.new(|cx| {
7377 crate::Thread::new(
7378 project.clone(),
7379 cx.new(|_cx| prompt_store::ProjectContext::default()),
7380 context_server_registry,
7381 templates.clone(),
7382 None,
7383 cx,
7384 )
7385 });
7386 let action_log = thread.read_with(cx, |thread, _cx| thread.action_log().clone());
7387
7388 #[allow(clippy::arc_with_non_send_sync)]
7389 let tool = Arc::new(crate::EditFileTool::new(
7390 project,
7391 thread.downgrade(),
7392 action_log,
7393 language_registry,
7394 ));
7395
7396 // Editing a file inside .zed/ should still prompt even with global default: allow,
7397 // because local settings paths are sensitive and require confirmation regardless.
7398 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7399 let _task = cx.update(|cx| {
7400 tool.run(
7401 ToolInput::resolved(crate::EditFileToolInput {
7402 path: "root/.zed/settings.json".into(),
7403 edits: vec![],
7404 }),
7405 event_stream,
7406 cx,
7407 )
7408 });
7409
7410 let _update = rx.expect_update_fields().await;
7411 let _auth = rx.expect_authorization().await;
7412}
7413
7414#[gpui::test]
7415async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) {
7416 init_test(cx);
7417
7418 cx.update(|cx| {
7419 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7420 settings.tool_permissions.tools.insert(
7421 FetchTool::NAME.into(),
7422 agent_settings::ToolRules {
7423 default: Some(settings::ToolPermissionMode::Allow),
7424 always_allow: vec![],
7425 always_deny: vec![
7426 agent_settings::CompiledRegex::new(r"internal\.company\.com", false).unwrap(),
7427 ],
7428 always_confirm: vec![],
7429 invalid_patterns: vec![],
7430 },
7431 );
7432 agent_settings::AgentSettings::override_global(settings, cx);
7433 });
7434
7435 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7436
7437 #[allow(clippy::arc_with_non_send_sync)]
7438 let tool = Arc::new(crate::FetchTool::new(http_client));
7439 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7440
7441 let input: crate::FetchToolInput =
7442 serde_json::from_value(json!({"url": "https://internal.company.com/api"})).unwrap();
7443
7444 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7445
7446 let result = task.await;
7447 assert!(result.is_err(), "expected fetch to be blocked");
7448 assert!(
7449 result.unwrap_err().contains("blocked"),
7450 "error should mention the fetch was blocked"
7451 );
7452}
7453
7454#[gpui::test]
7455async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) {
7456 init_test(cx);
7457
7458 cx.update(|cx| {
7459 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7460 settings.tool_permissions.tools.insert(
7461 FetchTool::NAME.into(),
7462 agent_settings::ToolRules {
7463 default: Some(settings::ToolPermissionMode::Confirm),
7464 always_allow: vec![agent_settings::CompiledRegex::new(r"docs\.rs", false).unwrap()],
7465 always_deny: vec![],
7466 always_confirm: vec![],
7467 invalid_patterns: vec![],
7468 },
7469 );
7470 // The fetch tool also gates on the shared per-host network grant, so
7471 // grant docs.rs to keep this URL fully silent.
7472 settings
7473 .sandbox_permissions
7474 .network_hosts
7475 .push("docs.rs".into());
7476 agent_settings::AgentSettings::override_global(settings, cx);
7477 });
7478
7479 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7480
7481 #[allow(clippy::arc_with_non_send_sync)]
7482 let tool = Arc::new(crate::FetchTool::new(http_client));
7483 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7484
7485 let input: crate::FetchToolInput =
7486 serde_json::from_value(json!({"url": "https://docs.rs/some-crate"})).unwrap();
7487
7488 let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7489
7490 cx.run_until_parked();
7491
7492 let event = rx.try_recv();
7493 assert!(
7494 !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
7495 "expected no authorization request for allowed and granted docs.rs URL"
7496 );
7497}
7498
7499/// A fetch to a host that hasn't been granted network access prompts for the
7500/// shared per-host sandbox grant, even when the tool itself is allowed.
7501#[gpui::test]
7502#[ignore]
7503async fn test_fetch_tool_prompts_for_ungranted_host(cx: &mut TestAppContext) {
7504 init_test(cx);
7505
7506 cx.update(|cx| {
7507 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7508 settings.tool_permissions.tools.insert(
7509 FetchTool::NAME.into(),
7510 agent_settings::ToolRules {
7511 default: Some(settings::ToolPermissionMode::Allow),
7512 always_allow: vec![],
7513 always_deny: vec![],
7514 always_confirm: vec![],
7515 invalid_patterns: vec![],
7516 },
7517 );
7518 agent_settings::AgentSettings::override_global(settings, cx);
7519 });
7520
7521 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7522
7523 #[allow(clippy::arc_with_non_send_sync)]
7524 let tool = Arc::new(crate::FetchTool::new(http_client));
7525 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7526
7527 let input: crate::FetchToolInput =
7528 serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
7529
7530 let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7531
7532 cx.run_until_parked();
7533
7534 let authorization = rx.expect_authorization().await;
7535 let details =
7536 acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
7537 .expect("an ungranted host should request a sandbox network grant");
7538 assert_eq!(details.network_hosts, vec!["example.com".to_string()]);
7539 assert!(!details.network_all_hosts);
7540}
7541
7542/// A host already present in the shared sandbox grants lets a fetch proceed
7543/// without any prompt — the same grant the terminal tool records and consults.
7544#[gpui::test]
7545async fn test_fetch_tool_granted_host_skips_prompt(cx: &mut TestAppContext) {
7546 init_test(cx);
7547
7548 cx.update(|cx| {
7549 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7550 // Allow the tool itself so only the shared per-host grant is under test.
7551 settings.tool_permissions.tools.insert(
7552 FetchTool::NAME.into(),
7553 agent_settings::ToolRules {
7554 default: Some(settings::ToolPermissionMode::Allow),
7555 always_allow: vec![],
7556 always_deny: vec![],
7557 always_confirm: vec![],
7558 invalid_patterns: vec![],
7559 },
7560 );
7561 settings
7562 .sandbox_permissions
7563 .network_hosts
7564 .push("example.com".into());
7565 agent_settings::AgentSettings::override_global(settings, cx);
7566 });
7567
7568 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7569
7570 #[allow(clippy::arc_with_non_send_sync)]
7571 let tool = Arc::new(crate::FetchTool::new(http_client));
7572 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7573
7574 let input: crate::FetchToolInput =
7575 serde_json::from_value(json!({"url": "https://example.com/page"})).unwrap();
7576
7577 let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7578
7579 cx.run_until_parked();
7580
7581 let event = rx.try_recv();
7582 assert!(
7583 !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
7584 "expected no authorization request for an already-granted host"
7585 );
7586}
7587
7588/// Loopback / IP-literal hosts can't be granted individually, so without
7589/// unsandboxed access a fetch to them is refused with guidance to grant it.
7590#[ignore]
7591#[gpui::test]
7592async fn test_fetch_tool_refuses_loopback_without_unsandboxed(cx: &mut TestAppContext) {
7593 init_test(cx);
7594
7595 cx.update(|cx| {
7596 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7597 // Allow the tool itself so the request reaches the per-host gate.
7598 settings.tool_permissions.tools.insert(
7599 FetchTool::NAME.into(),
7600 agent_settings::ToolRules {
7601 default: Some(settings::ToolPermissionMode::Allow),
7602 always_allow: vec![],
7603 always_deny: vec![],
7604 always_confirm: vec![],
7605 invalid_patterns: vec![],
7606 },
7607 );
7608 agent_settings::AgentSettings::override_global(settings, cx);
7609 });
7610
7611 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7612
7613 #[allow(clippy::arc_with_non_send_sync)]
7614 let tool = Arc::new(crate::FetchTool::new(http_client));
7615 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7616
7617 let input: crate::FetchToolInput =
7618 serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
7619
7620 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7621 let result = task.await;
7622 assert!(result.is_err(), "expected a loopback fetch to be refused");
7623 assert!(
7624 result.unwrap_err().contains("unsandboxed"),
7625 "error should point at unsandboxed access as the way to reach loopback hosts"
7626 );
7627}
7628
7629/// Granting unsandboxed access lifts every fetch restriction, matching the
7630/// terminal: even loopback hosts become reachable and no per-host prompt is
7631/// requested.
7632#[gpui::test]
7633async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext) {
7634 init_test(cx);
7635
7636 cx.update(|cx| {
7637 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7638 settings.sandbox_permissions.allow_unsandboxed = true;
7639 // Allow the tool itself so only the per-host gate is under test.
7640 settings.tool_permissions.tools.insert(
7641 FetchTool::NAME.into(),
7642 agent_settings::ToolRules {
7643 default: Some(settings::ToolPermissionMode::Allow),
7644 always_allow: vec![],
7645 always_deny: vec![],
7646 always_confirm: vec![],
7647 invalid_patterns: vec![],
7648 },
7649 );
7650 agent_settings::AgentSettings::override_global(settings, cx);
7651 });
7652
7653 let http_client = gpui::http_client::FakeHttpClient::with_200_response();
7654
7655 #[allow(clippy::arc_with_non_send_sync)]
7656 let tool = Arc::new(crate::FetchTool::new(http_client));
7657 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7658
7659 // A loopback host that could never be granted individually is reachable,
7660 // and no per-host authorization is requested.
7661 let input: crate::FetchToolInput =
7662 serde_json::from_value(json!({"url": "http://localhost:3000/api"})).unwrap();
7663
7664 let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7665
7666 cx.run_until_parked();
7667
7668 let event = rx.try_recv();
7669 assert!(
7670 !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
7671 "expected no authorization request when unsandboxed access is granted"
7672 );
7673}
7674
7675/// A granted host that redirects to a loopback target must not have that
7676/// redirect followed: loopback hosts can't be granted individually, so the hop
7677/// is refused just like a direct loopback fetch. This is the redirect variant of
7678/// the SSRF protection — the approved domain can't be used to bounce the request
7679/// onto the local machine.
7680#[ignore]
7681#[gpui::test]
7682async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) {
7683 init_test(cx);
7684
7685 cx.update(|cx| {
7686 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7687 settings.tool_permissions.tools.insert(
7688 FetchTool::NAME.into(),
7689 agent_settings::ToolRules {
7690 default: Some(settings::ToolPermissionMode::Allow),
7691 always_allow: vec![],
7692 always_deny: vec![],
7693 always_confirm: vec![],
7694 invalid_patterns: vec![],
7695 },
7696 );
7697 settings
7698 .sandbox_permissions
7699 .network_hosts
7700 .push("example.com".into());
7701 agent_settings::AgentSettings::override_global(settings, cx);
7702 });
7703
7704 let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
7705 let uri = req.uri().to_string();
7706 assert!(
7707 uri.contains("example.com"),
7708 "the loopback redirect target must never be requested, but saw {uri}"
7709 );
7710 Ok(gpui::http_client::Response::builder()
7711 .status(302)
7712 .header("location", "http://localhost:3000/internal")
7713 .body("".into())
7714 .unwrap())
7715 });
7716
7717 #[allow(clippy::arc_with_non_send_sync)]
7718 let tool = Arc::new(crate::FetchTool::new(http_client));
7719 let (event_stream, _rx) = crate::ToolCallEventStream::test();
7720
7721 let input: crate::FetchToolInput =
7722 serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
7723
7724 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7725 let result = task.await;
7726 assert!(
7727 result.is_err(),
7728 "expected a redirect to a loopback host to be refused"
7729 );
7730 assert!(
7731 result.unwrap_err().contains("unsandboxed"),
7732 "error should point at unsandboxed access as the way to reach loopback hosts"
7733 );
7734}
7735
7736/// A granted host that redirects to a *different*, ungranted host triggers a
7737/// fresh per-host authorization prompt for the redirect target — the redirect is
7738/// not silently followed to a host the user never approved.
7739#[ignore]
7740#[gpui::test]
7741async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) {
7742 init_test(cx);
7743
7744 cx.update(|cx| {
7745 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7746 settings.tool_permissions.tools.insert(
7747 FetchTool::NAME.into(),
7748 agent_settings::ToolRules {
7749 default: Some(settings::ToolPermissionMode::Allow),
7750 always_allow: vec![],
7751 always_deny: vec![],
7752 always_confirm: vec![],
7753 invalid_patterns: vec![],
7754 },
7755 );
7756 settings
7757 .sandbox_permissions
7758 .network_hosts
7759 .push("example.com".into());
7760 agent_settings::AgentSettings::override_global(settings, cx);
7761 });
7762
7763 let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
7764 let uri = req.uri().to_string();
7765 assert!(
7766 uri.contains("example.com"),
7767 "the ungranted redirect target must not be requested before authorization, \
7768 but saw {uri}"
7769 );
7770 Ok(gpui::http_client::Response::builder()
7771 .status(302)
7772 .header("location", "https://redirect-target.example/landing")
7773 .body("".into())
7774 .unwrap())
7775 });
7776
7777 #[allow(clippy::arc_with_non_send_sync)]
7778 let tool = Arc::new(crate::FetchTool::new(http_client));
7779 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7780
7781 let input: crate::FetchToolInput =
7782 serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
7783
7784 let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7785
7786 cx.run_until_parked();
7787
7788 let authorization = rx.expect_authorization().await;
7789 let details =
7790 acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
7791 .expect("a redirect to an ungranted host should request a sandbox network grant");
7792 assert_eq!(
7793 details.network_hosts,
7794 vec!["redirect-target.example".to_string()]
7795 );
7796 assert!(!details.network_all_hosts);
7797}
7798
7799/// Redirects between paths on an already-granted host are followed without any
7800/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash
7801/// canonicalization, etc.) keep working after the per-hop authorization change.
7802#[gpui::test]
7803async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) {
7804 init_test(cx);
7805
7806 cx.update(|cx| {
7807 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7808 settings.tool_permissions.tools.insert(
7809 FetchTool::NAME.into(),
7810 agent_settings::ToolRules {
7811 default: Some(settings::ToolPermissionMode::Allow),
7812 always_allow: vec![],
7813 always_deny: vec![],
7814 always_confirm: vec![],
7815 invalid_patterns: vec![],
7816 },
7817 );
7818 settings
7819 .sandbox_permissions
7820 .network_hosts
7821 .push("example.com".into());
7822 agent_settings::AgentSettings::override_global(settings, cx);
7823 });
7824
7825 let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
7826 let uri = req.uri().to_string();
7827 if uri.ends_with("/start") {
7828 Ok(gpui::http_client::Response::builder()
7829 .status(302)
7830 .header("location", "https://example.com/final")
7831 .body("".into())
7832 .unwrap())
7833 } else if uri.ends_with("/final") {
7834 Ok(gpui::http_client::Response::builder()
7835 .status(200)
7836 .header("content-type", "text/plain")
7837 .body("final content".into())
7838 .unwrap())
7839 } else {
7840 panic!("unexpected request to {uri}");
7841 }
7842 });
7843
7844 #[allow(clippy::arc_with_non_send_sync)]
7845 let tool = Arc::new(crate::FetchTool::new(http_client));
7846 let (event_stream, mut rx) = crate::ToolCallEventStream::test();
7847
7848 let input: crate::FetchToolInput =
7849 serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
7850
7851 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
7852 let result = task.await;
7853 assert_eq!(
7854 result.expect("same-host redirect should succeed"),
7855 "final content"
7856 );
7857
7858 let event = rx.try_recv();
7859 assert!(
7860 !matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
7861 "expected no authorization prompt for a redirect to an already-granted host"
7862 );
7863}
7864
7865/// Approving one pending tool call with "Always for <tool>" auto-resolves
7866/// sibling pending authorizations for the same tool in the same turn.
7867#[gpui::test]
7868async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppContext) {
7869 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
7870 let fake_model = model.as_fake();
7871
7872 let mut events = thread
7873 .update(cx, |thread, cx| {
7874 thread.add_tool(ToolRequiringPermission);
7875 thread.send(ClientUserMessageId::new(), ["abc"], cx)
7876 })
7877 .unwrap();
7878 cx.run_until_parked();
7879
7880 // Two parallel tool calls, both require permission.
7881 for id in ["tool_id_1", "tool_id_2"] {
7882 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
7883 LanguageModelToolUse {
7884 id: id.into(),
7885 name: ToolRequiringPermission::NAME.into(),
7886 raw_input: "{}".into(),
7887 input: language_model::LanguageModelToolUseInput::Json(json!({})),
7888 is_input_complete: true,
7889 thought_signature: None,
7890 },
7891 ));
7892 }
7893 fake_model.end_last_completion_stream();
7894
7895 let tool_call_auth_1 = next_tool_call_authorization(&mut events).await;
7896 let tool_call_auth_2 = next_tool_call_authorization(&mut events).await;
7897
7898 // Approve the first with "always allow" — this persists a setting that
7899 // makes the tool unconditionally allowed. The second pending
7900 // authorization should resolve without user interaction.
7901 tool_call_auth_1
7902 .response
7903 .send(acp_thread::SelectedPermissionOutcome::new(
7904 acp::PermissionOptionId::new("always_allow:tool_requiring_permission"),
7905 acp::PermissionOptionKind::AllowAlways,
7906 ))
7907 .unwrap();
7908 cx.run_until_parked();
7909
7910 // The second tool's receiver was dropped by the auto-resolve path, so
7911 // sending a late response should fail.
7912 let late_send = tool_call_auth_2
7913 .response
7914 .send(acp_thread::SelectedPermissionOutcome::new(
7915 acp::PermissionOptionId::new("allow"),
7916 acp::PermissionOptionKind::AllowOnce,
7917 ));
7918 assert!(
7919 late_send.is_err(),
7920 "expected tool 2's response receiver to be dropped after auto-resolve"
7921 );
7922
7923 let completion = fake_model.pending_completions().pop().unwrap();
7924 let message = completion.messages.last().unwrap();
7925 let results: Vec<_> = message
7926 .content
7927 .iter()
7928 .filter_map(|c| match c {
7929 language_model::MessageContent::ToolResult(r) => Some(r),
7930 _ => None,
7931 })
7932 .collect();
7933 assert_eq!(
7934 results.len(),
7935 2,
7936 "both tool calls should have produced results"
7937 );
7938 assert!(
7939 results.iter().all(|r| !r.is_error),
7940 "both results should be successful after auto-resolve, got: {:?}",
7941 results
7942 );
7943}
7944
7945/// Externally editing settings (e.g. the user opening settings.json and
7946/// adding an `always_allow` rule) resolves pending authorization prompts
7947/// for tool calls that match the new rule.
7948#[gpui::test]
7949async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut TestAppContext) {
7950 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
7951 let fake_model = model.as_fake();
7952
7953 let mut events = thread
7954 .update(cx, |thread, cx| {
7955 thread.add_tool(ToolRequiringPermission);
7956 thread.send(ClientUserMessageId::new(), ["abc"], cx)
7957 })
7958 .unwrap();
7959 cx.run_until_parked();
7960
7961 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
7962 LanguageModelToolUse {
7963 id: "tool_id_1".into(),
7964 name: ToolRequiringPermission::NAME.into(),
7965 raw_input: "{}".into(),
7966 input: language_model::LanguageModelToolUseInput::Json(json!({})),
7967 is_input_complete: true,
7968 thought_signature: None,
7969 },
7970 ));
7971 fake_model.end_last_completion_stream();
7972
7973 let tool_call_auth = next_tool_call_authorization(&mut events).await;
7974
7975 // Simulate the user editing settings.json to globally allow the tool.
7976 cx.update(|cx| {
7977 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
7978 settings.tool_permissions.tools.insert(
7979 ToolRequiringPermission::NAME.into(),
7980 agent_settings::ToolRules {
7981 default: Some(settings::ToolPermissionMode::Allow),
7982 always_allow: vec![],
7983 always_deny: vec![],
7984 always_confirm: vec![],
7985 invalid_patterns: vec![],
7986 },
7987 );
7988 agent_settings::AgentSettings::override_global(settings, cx);
7989 });
7990 cx.run_until_parked();
7991
7992 // The pending prompt auto-resolves without the user clicking anything.
7993 let late_send = tool_call_auth
7994 .response
7995 .send(acp_thread::SelectedPermissionOutcome::new(
7996 acp::PermissionOptionId::new("allow"),
7997 acp::PermissionOptionKind::AllowOnce,
7998 ));
7999 assert!(
8000 late_send.is_err(),
8001 "response receiver should have been dropped after settings-driven auto-resolve"
8002 );
8003
8004 let completion = fake_model.pending_completions().pop().unwrap();
8005 let message = completion.messages.last().unwrap();
8006 let result = message
8007 .content
8008 .iter()
8009 .find_map(|c| match c {
8010 language_model::MessageContent::ToolResult(r) => Some(r),
8011 _ => None,
8012 })
8013 .expect("expected a tool result");
8014 assert!(!result.is_error, "tool should have been auto-allowed");
8015}
8016
8017/// Externally adding a deny rule to settings dismisses a pending
8018/// authorization prompt and returns the tool call as denied.
8019#[gpui::test]
8020async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestAppContext) {
8021 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8022 let fake_model = model.as_fake();
8023
8024 let mut events = thread
8025 .update(cx, |thread, cx| {
8026 thread.add_tool(ToolRequiringPermission);
8027 thread.send(ClientUserMessageId::new(), ["abc"], cx)
8028 })
8029 .unwrap();
8030 cx.run_until_parked();
8031
8032 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8033 LanguageModelToolUse {
8034 id: "tool_id_1".into(),
8035 name: ToolRequiringPermission::NAME.into(),
8036 raw_input: "{}".into(),
8037 input: language_model::LanguageModelToolUseInput::Json(json!({})),
8038 is_input_complete: true,
8039 thought_signature: None,
8040 },
8041 ));
8042 fake_model.end_last_completion_stream();
8043
8044 let tool_call_auth = next_tool_call_authorization(&mut events).await;
8045
8046 // Simulate the user adding a deny default for the tool.
8047 cx.update(|cx| {
8048 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
8049 settings.tool_permissions.tools.insert(
8050 ToolRequiringPermission::NAME.into(),
8051 agent_settings::ToolRules {
8052 default: Some(settings::ToolPermissionMode::Deny),
8053 always_allow: vec![],
8054 always_deny: vec![],
8055 always_confirm: vec![],
8056 invalid_patterns: vec![],
8057 },
8058 );
8059 agent_settings::AgentSettings::override_global(settings, cx);
8060 });
8061 cx.run_until_parked();
8062
8063 let late_send = tool_call_auth
8064 .response
8065 .send(acp_thread::SelectedPermissionOutcome::new(
8066 acp::PermissionOptionId::new("allow"),
8067 acp::PermissionOptionKind::AllowOnce,
8068 ));
8069 assert!(
8070 late_send.is_err(),
8071 "response receiver should have been dropped after deny auto-resolve"
8072 );
8073
8074 let completion = fake_model.pending_completions().pop().unwrap();
8075 let message = completion.messages.last().unwrap();
8076 let result = message
8077 .content
8078 .iter()
8079 .find_map(|c| match c {
8080 language_model::MessageContent::ToolResult(r) => Some(r),
8081 _ => None,
8082 })
8083 .expect("expected a tool result");
8084 assert!(
8085 result.is_error,
8086 "tool should have been auto-denied by the new rule"
8087 );
8088}
8089
8090/// Unrelated settings changes must not spuriously resolve pending
8091/// authorizations: if the re-check still returns `Confirm`, the prompt
8092/// stays visible and waits for the user.
8093#[gpui::test]
8094async fn test_unrelated_settings_change_does_not_resolve_pending_authorization(
8095 cx: &mut TestAppContext,
8096) {
8097 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8098 let fake_model = model.as_fake();
8099
8100 let mut events = thread
8101 .update(cx, |thread, cx| {
8102 thread.add_tool(ToolRequiringPermission);
8103 thread.send(ClientUserMessageId::new(), ["abc"], cx)
8104 })
8105 .unwrap();
8106 cx.run_until_parked();
8107
8108 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8109 LanguageModelToolUse {
8110 id: "tool_id_1".into(),
8111 name: ToolRequiringPermission::NAME.into(),
8112 raw_input: "{}".into(),
8113 input: language_model::LanguageModelToolUseInput::Json(json!({})),
8114 is_input_complete: true,
8115 thought_signature: None,
8116 },
8117 ));
8118 fake_model.end_last_completion_stream();
8119
8120 let tool_call_auth = next_tool_call_authorization(&mut events).await;
8121
8122 // Touch SettingsStore with a change that doesn't affect tool
8123 // permissions; the pending authorization should remain pending.
8124 cx.update(|cx| {
8125 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
8126 settings.single_file_review = !settings.single_file_review;
8127 agent_settings::AgentSettings::override_global(settings, cx);
8128 });
8129 cx.run_until_parked();
8130
8131 // The user still has to act — resolve with an Allow Once.
8132 tool_call_auth
8133 .response
8134 .send(acp_thread::SelectedPermissionOutcome::new(
8135 acp::PermissionOptionId::new("allow"),
8136 acp::PermissionOptionKind::AllowOnce,
8137 ))
8138 .expect("response receiver should still be alive");
8139 cx.run_until_parked();
8140
8141 let completion = fake_model.pending_completions().pop().unwrap();
8142 let message = completion.messages.last().unwrap();
8143 let result = message
8144 .content
8145 .iter()
8146 .find_map(|c| match c {
8147 language_model::MessageContent::ToolResult(r) => Some(r),
8148 _ => None,
8149 })
8150 .expect("expected a tool result");
8151 assert!(!result.is_error);
8152}
8153
8154/// Approving one pending tool call with "Always for <tool A>" must not
8155/// dismiss a sibling pending authorization for a *different* tool: the
8156/// persisted rule is scoped to tool A, so tool B's prompt stays visible
8157/// and waits for the user.
8158#[gpui::test]
8159async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mut TestAppContext) {
8160 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8161 let fake_model = model.as_fake();
8162
8163 let mut events = thread
8164 .update(cx, |thread, cx| {
8165 thread.add_tool(ToolRequiringPermission);
8166 thread.add_tool(ToolRequiringPermission2);
8167 thread.send(ClientUserMessageId::new(), ["abc"], cx)
8168 })
8169 .unwrap();
8170 cx.run_until_parked();
8171
8172 // Two parallel tool calls, each for a distinct tool with its own
8173 // permission scope.
8174 for (id, name) in [
8175 ("tool_id_1", ToolRequiringPermission::NAME),
8176 ("tool_id_2", ToolRequiringPermission2::NAME),
8177 ] {
8178 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8179 LanguageModelToolUse {
8180 id: id.into(),
8181 name: name.into(),
8182 raw_input: "{}".into(),
8183 input: language_model::LanguageModelToolUseInput::Json(json!({})),
8184 is_input_complete: true,
8185 thought_signature: None,
8186 },
8187 ));
8188 }
8189 fake_model.end_last_completion_stream();
8190
8191 let auth_a = next_tool_call_authorization(&mut events).await;
8192 let auth_b = next_tool_call_authorization(&mut events).await;
8193
8194 // Match prompts back to their originating tools via the authorization
8195 // context so the test doesn't depend on scheduling order.
8196 let (auth_for_tool_1, auth_for_tool_2) = {
8197 let a_name = auth_a
8198 .context
8199 .as_ref()
8200 .expect("settings-driven authorization must carry a context")
8201 .tool_name
8202 .clone();
8203 if a_name == ToolRequiringPermission::NAME {
8204 (auth_a, auth_b)
8205 } else {
8206 (auth_b, auth_a)
8207 }
8208 };
8209
8210 // Approve tool 1 with "always allow". Only tool 1's rule is persisted.
8211 auth_for_tool_1
8212 .response
8213 .send(acp_thread::SelectedPermissionOutcome::new(
8214 acp::PermissionOptionId::new("always_allow:tool_requiring_permission"),
8215 acp::PermissionOptionKind::AllowAlways,
8216 ))
8217 .unwrap();
8218 cx.run_until_parked();
8219
8220 // Tool 2's receiver must still be alive: its permission is unrelated
8221 // to the rule that was just added, so its prompt stays pending.
8222 auth_for_tool_2
8223 .response
8224 .send(acp_thread::SelectedPermissionOutcome::new(
8225 acp::PermissionOptionId::new("allow"),
8226 acp::PermissionOptionKind::AllowOnce,
8227 ))
8228 .expect("tool 2's response receiver should still be alive");
8229 cx.run_until_parked();
8230
8231 let completion = fake_model.pending_completions().pop().unwrap();
8232 let message = completion.messages.last().unwrap();
8233 let results: Vec<_> = message
8234 .content
8235 .iter()
8236 .filter_map(|c| match c {
8237 language_model::MessageContent::ToolResult(r) => Some(r),
8238 _ => None,
8239 })
8240 .collect();
8241 assert_eq!(
8242 results.len(),
8243 2,
8244 "both tool calls should have produced results"
8245 );
8246 assert!(
8247 results.iter().all(|r| !r.is_error),
8248 "both results should be successful, got: {:?}",
8249 results
8250 );
8251}
8252
8253#[gpui::test]
8254async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) {
8255 init_test(cx);
8256 always_allow_tools(cx);
8257
8258 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8259 let fake_model = model.as_fake();
8260
8261 // Add a tool so we can simulate tool calls
8262 thread.update(cx, |thread, _cx| {
8263 thread.add_tool(EchoTool);
8264 });
8265
8266 // Start a turn by sending a message
8267 let mut events = thread
8268 .update(cx, |thread, cx| {
8269 thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx)
8270 })
8271 .unwrap();
8272 cx.run_until_parked();
8273
8274 // Simulate the model making a tool call
8275 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8276 LanguageModelToolUse {
8277 id: "tool_1".into(),
8278 name: "echo".into(),
8279 raw_input: r#"{"text": "hello"}"#.into(),
8280 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
8281 is_input_complete: true,
8282 thought_signature: None,
8283 },
8284 ));
8285 fake_model
8286 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse));
8287
8288 // Request that the turn end at the next boundary (a "steering" queued message)
8289 thread.update(cx, |thread, _cx| {
8290 thread.set_end_turn_at_next_boundary(true);
8291 });
8292
8293 // Now end the stream - tool will run, and the boundary check should see the queue
8294 fake_model.end_last_completion_stream();
8295
8296 // Collect all events until the turn stops
8297 let all_events = collect_events_until_stop(&mut events, cx).await;
8298
8299 // Verify we received the tool call event
8300 let tool_call_ids: Vec<_> = all_events
8301 .iter()
8302 .filter_map(|e| match e {
8303 Ok(ThreadEvent::ToolCall(tc)) => Some(tc.tool_call_id.to_string()),
8304 _ => None,
8305 })
8306 .collect();
8307 // User message is index 0, so the tool call is scoped to index 1 (see
8308 // `scoped_tool_call_id`).
8309 assert_eq!(
8310 tool_call_ids,
8311 vec![scoped_tool_call_id(1, &"tool_1".into()).to_string()],
8312 "Should have received a tool call event for our echo tool"
8313 );
8314
8315 // The turn should have stopped with EndTurn
8316 let stop_reasons = stop_events(all_events);
8317 assert_eq!(
8318 stop_reasons,
8319 vec![acp::StopReason::EndTurn],
8320 "Turn should have ended after tool completion due to queued message"
8321 );
8322
8323 // Verify the boundary flag is still set
8324 thread.update(cx, |thread, _cx| {
8325 assert!(
8326 thread.end_turn_at_next_boundary(),
8327 "Should still have the end-turn-at-boundary flag set"
8328 );
8329 });
8330
8331 // Thread should be idle now
8332 thread.update(cx, |thread, _cx| {
8333 assert!(
8334 thread.is_turn_complete(),
8335 "Thread should not be running after turn ends"
8336 );
8337 });
8338}
8339
8340#[gpui::test]
8341async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut TestAppContext) {
8342 init_test(cx);
8343 always_allow_tools(cx);
8344
8345 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8346 let fake_model = model.as_fake();
8347
8348 thread.update(cx, |thread, _cx| {
8349 thread.add_tool(EchoTool);
8350 });
8351
8352 let mut events = thread
8353 .update(cx, |thread, cx| {
8354 thread.send(ClientUserMessageId::new(), ["Use the echo tool"], cx)
8355 })
8356 .unwrap();
8357 cx.run_until_parked();
8358
8359 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8360 LanguageModelToolUse {
8361 id: "tool_1".into(),
8362 name: "echo".into(),
8363 raw_input: r#"{"text": "hello"}"#.into(),
8364 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
8365 is_input_complete: true,
8366 thought_signature: None,
8367 },
8368 ));
8369 fake_model
8370 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::ToolUse));
8371
8372 // Default behavior: even though a message is conceptually queued, we do NOT
8373 // set the boundary flag, so the agent must keep going past the tool boundary
8374 // (running to completion) rather than ending the turn early.
8375 fake_model.end_last_completion_stream();
8376 cx.run_until_parked();
8377
8378 // The agent should have issued a fresh completion request with the tool
8379 // results instead of stopping — proof it continued past the boundary.
8380 let continuation = fake_model.pending_completions();
8381 assert_eq!(
8382 continuation.len(),
8383 1,
8384 "Without the boundary flag, the turn should continue with another completion request"
8385 );
8386
8387 // Let the continuation finish the turn naturally.
8388 fake_model.send_last_completion_stream_text_chunk("All done");
8389 fake_model
8390 .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn));
8391 fake_model.end_last_completion_stream();
8392
8393 let all_events = collect_events_until_stop(&mut events, cx).await;
8394 let stop_reasons = stop_events(all_events);
8395 assert_eq!(
8396 stop_reasons,
8397 vec![acp::StopReason::EndTurn],
8398 "Turn should end only after the agent finishes, not at the tool boundary"
8399 );
8400}
8401
8402#[gpui::test]
8403async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestAppContext) {
8404 init_test(cx);
8405 always_allow_tools(cx);
8406
8407 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8408 let fake_model = model.as_fake();
8409
8410 thread.update(cx, |thread, _cx| {
8411 thread.add_tool(StreamingFailingEchoTool {
8412 receive_chunks_until_failure: 1,
8413 });
8414 });
8415
8416 let _events = thread
8417 .update(cx, |thread, cx| {
8418 thread.send(
8419 ClientUserMessageId::new(),
8420 ["Use the streaming_failing_echo tool"],
8421 cx,
8422 )
8423 })
8424 .unwrap();
8425 cx.run_until_parked();
8426
8427 let tool_use = LanguageModelToolUse {
8428 id: "call_1".into(),
8429 name: StreamingFailingEchoTool::NAME.into(),
8430 raw_input: "hello".into(),
8431 input: language_model::LanguageModelToolUseInput::Json(json!({})),
8432 is_input_complete: false,
8433 thought_signature: None,
8434 };
8435
8436 fake_model
8437 .send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
8438
8439 cx.run_until_parked();
8440
8441 let completions = fake_model.pending_completions();
8442 let last_completion = completions.last().unwrap();
8443
8444 assert_eq!(
8445 last_completion.messages[1..],
8446 vec![
8447 LanguageModelRequestMessage {
8448 role: Role::User,
8449 content: vec!["Use the streaming_failing_echo tool".into()],
8450 cache: false,
8451 reasoning_details: None,
8452 },
8453 LanguageModelRequestMessage {
8454 role: Role::Assistant,
8455 content: vec![language_model::MessageContent::ToolUse(tool_use.clone())],
8456 cache: false,
8457 reasoning_details: None,
8458 },
8459 LanguageModelRequestMessage {
8460 role: Role::User,
8461 content: vec![language_model::MessageContent::ToolResult(
8462 LanguageModelToolResult {
8463 tool_use_id: tool_use.id.clone(),
8464 tool_name: tool_use.name,
8465 is_error: true,
8466 content: vec!["failed".into()],
8467 output: Some("failed".into()),
8468 }
8469 )],
8470 cache: true,
8471 reasoning_details: None,
8472 },
8473 ]
8474 );
8475}
8476
8477#[gpui::test]
8478async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut TestAppContext) {
8479 init_test(cx);
8480 always_allow_tools(cx);
8481
8482 let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
8483 let fake_model = model.as_fake();
8484
8485 let (complete_streaming_echo_tool_call_tx, complete_streaming_echo_tool_call_rx) =
8486 oneshot::channel();
8487
8488 thread.update(cx, |thread, _cx| {
8489 thread.add_tool(
8490 StreamingEchoTool::new().with_wait_until_complete(complete_streaming_echo_tool_call_rx),
8491 );
8492 thread.add_tool(StreamingFailingEchoTool {
8493 receive_chunks_until_failure: 1,
8494 });
8495 });
8496
8497 let _events = thread
8498 .update(cx, |thread, cx| {
8499 thread.send(
8500 ClientUserMessageId::new(),
8501 ["Use the streaming_echo tool and the streaming_failing_echo tool"],
8502 cx,
8503 )
8504 })
8505 .unwrap();
8506 cx.run_until_parked();
8507
8508 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8509 LanguageModelToolUse {
8510 id: "call_1".into(),
8511 name: StreamingEchoTool::NAME.into(),
8512 raw_input: "hello".into(),
8513 input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
8514 is_input_complete: false,
8515 thought_signature: None,
8516 },
8517 ));
8518 let first_tool_use = LanguageModelToolUse {
8519 id: "call_1".into(),
8520 name: StreamingEchoTool::NAME.into(),
8521 raw_input: "hello world".into(),
8522 input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })),
8523 is_input_complete: true,
8524 thought_signature: None,
8525 };
8526 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8527 first_tool_use.clone(),
8528 ));
8529 let second_tool_use = LanguageModelToolUse {
8530 name: StreamingFailingEchoTool::NAME.into(),
8531 raw_input: "hello".into(),
8532 input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
8533 is_input_complete: false,
8534 thought_signature: None,
8535 id: "call_2".into(),
8536 };
8537 fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8538 second_tool_use.clone(),
8539 ));
8540
8541 cx.run_until_parked();
8542
8543 complete_streaming_echo_tool_call_tx.send(()).unwrap();
8544
8545 cx.run_until_parked();
8546
8547 let completions = fake_model.pending_completions();
8548 let last_completion = completions.last().unwrap();
8549
8550 assert_eq!(
8551 last_completion.messages[1..],
8552 vec![
8553 LanguageModelRequestMessage {
8554 role: Role::User,
8555 content: vec![
8556 "Use the streaming_echo tool and the streaming_failing_echo tool".into()
8557 ],
8558 cache: false,
8559 reasoning_details: None,
8560 },
8561 LanguageModelRequestMessage {
8562 role: Role::Assistant,
8563 content: vec![
8564 language_model::MessageContent::ToolUse(first_tool_use.clone()),
8565 language_model::MessageContent::ToolUse(second_tool_use.clone())
8566 ],
8567 cache: false,
8568 reasoning_details: None,
8569 },
8570 LanguageModelRequestMessage {
8571 role: Role::User,
8572 content: vec![
8573 language_model::MessageContent::ToolResult(LanguageModelToolResult {
8574 tool_use_id: second_tool_use.id.clone(),
8575 tool_name: second_tool_use.name,
8576 is_error: true,
8577 content: vec!["failed".into()],
8578 output: Some("failed".into()),
8579 }),
8580 language_model::MessageContent::ToolResult(LanguageModelToolResult {
8581 tool_use_id: first_tool_use.id.clone(),
8582 tool_name: first_tool_use.name,
8583 is_error: false,
8584 content: vec!["hello world".into()],
8585 output: Some("hello world".into()),
8586 }),
8587 ],
8588 cache: true,
8589 reasoning_details: None,
8590 },
8591 ]
8592 );
8593}
8594
8595#[gpui::test]
8596async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) {
8597 let ThreadTest {
8598 model, thread, fs, ..
8599 } = setup(cx, TestModel::Fake).await;
8600 let fake_model_a = model.as_fake();
8601
8602 thread.update(cx, |thread, _cx| {
8603 thread.add_tool(EchoTool);
8604 thread.add_tool(DelayTool);
8605 });
8606
8607 // Set up two profiles: profile-a has both tools, profile-b has only DelayTool.
8608 fs.insert_file(
8609 paths::settings_file(),
8610 json!({
8611 "agent": {
8612 "profiles": {
8613 "profile-a": {
8614 "name": "Profile A",
8615 "tools": {
8616 EchoTool::NAME: true,
8617 DelayTool::NAME: true,
8618 }
8619 },
8620 "profile-b": {
8621 "name": "Profile B",
8622 "tools": {
8623 DelayTool::NAME: true,
8624 }
8625 }
8626 }
8627 }
8628 })
8629 .to_string()
8630 .into_bytes(),
8631 )
8632 .await;
8633 cx.run_until_parked();
8634
8635 thread.update(cx, |thread, cx| {
8636 thread.set_profile(AgentProfileId("profile-a".into()), cx);
8637 thread.set_thinking_enabled(false, cx);
8638 });
8639
8640 // Send a message — first iteration starts with model A, profile-a, thinking off.
8641 thread
8642 .update(cx, |thread, cx| {
8643 thread.send(ClientUserMessageId::new(), ["test mid-turn refresh"], cx)
8644 })
8645 .unwrap();
8646 cx.run_until_parked();
8647
8648 // Verify first request has both tools and thinking disabled.
8649 let completions = fake_model_a.pending_completions();
8650 assert_eq!(completions.len(), 1);
8651 let first_tools = tool_names_for_completion(&completions[0]);
8652 assert_eq!(first_tools, vec![DelayTool::NAME, EchoTool::NAME]);
8653 assert!(!completions[0].thinking_allowed);
8654
8655 // Model A responds with an echo tool call.
8656 fake_model_a.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
8657 LanguageModelToolUse {
8658 id: "tool_1".into(),
8659 name: "echo".into(),
8660 raw_input: r#"{"text":"hello"}"#.into(),
8661 input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
8662 is_input_complete: true,
8663 thought_signature: None,
8664 },
8665 ));
8666 fake_model_a.end_last_completion_stream();
8667
8668 // Before the next iteration runs, switch to profile-b (only DelayTool),
8669 // swap in a new model, and enable thinking.
8670 let fake_model_b = Arc::new(FakeLanguageModel::with_id_and_thinking(
8671 "test-provider",
8672 "model-b",
8673 "Model B",
8674 true,
8675 ));
8676 thread.update(cx, |thread, cx| {
8677 thread.set_profile(AgentProfileId("profile-b".into()), cx);
8678 thread.set_model(fake_model_b.clone() as Arc<dyn LanguageModel>, cx);
8679 thread.set_thinking_enabled(true, cx);
8680 });
8681
8682 // Run until parked — processes the echo tool call, loops back, picks up
8683 // the new model/profile/thinking, and makes a second request to model B.
8684 cx.run_until_parked();
8685
8686 // The second request should have gone to model B.
8687 let model_b_completions = fake_model_b.pending_completions();
8688 assert_eq!(
8689 model_b_completions.len(),
8690 1,
8691 "second request should go to model B"
8692 );
8693
8694 // Profile-b only has DelayTool, so echo should be gone.
8695 let second_tools = tool_names_for_completion(&model_b_completions[0]);
8696 assert_eq!(second_tools, vec![DelayTool::NAME]);
8697
8698 // Thinking should now be enabled.
8699 assert!(model_b_completions[0].thinking_allowed);
8700}
8701