Skip to repository content10209 lines · 372.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:24:07.677Z 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
acp_thread.rs
1mod connection;
2mod diff;
3mod mention;
4mod terminal;
5pub use ::terminal::HeadlessTerminal;
6use action_log::{ActionLog, ActionLogTelemetry};
7use agent_client_protocol::schema::{MaybeUndefined, v1 as acp};
8use anyhow::{Context as _, Result, anyhow};
9use collections::HashSet;
10pub use connection::*;
11pub use diff::*;
12use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
13use futures::{FutureExt, channel::oneshot, future::BoxFuture};
14use gpui::{
15 AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
16 WeakEntity,
17};
18use itertools::Itertools;
19use language::language_settings::FormatOnSave;
20use language::{
21 Anchor, Buffer, BufferEditSource, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff,
22};
23use markdown::{Markdown, MarkdownOptions};
24pub use mention::*;
25use project::lsp_store::{FormatTrigger, LspFormatTarget};
26use project::{
27 AgentLocation, Project,
28 git_store::{GitStoreCheckpoint, GitStoreEvent, RepositoryEvent},
29};
30use serde::{Deserialize, Serialize};
31use serde_json::to_string_pretty;
32use std::collections::HashMap;
33use std::error::Error;
34use std::fmt::{Formatter, Write};
35use std::ops::Range;
36use std::process::ExitStatus;
37use std::rc::Rc;
38use std::time::{Duration, Instant};
39use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
40use task::{Shell, ShellBuilder};
41pub use terminal::*;
42use text::Bias;
43use ui::App;
44use util::markdown::MarkdownEscaped;
45use util::path_list::PathList;
46use util::{
47 ResultExt, get_default_system_shell_preferring_bash,
48 paths::{PathStyle, is_absolute},
49};
50use uuid::Uuid;
51
52/// Returned when the model stops because it exhausted its output token budget.
53#[derive(Debug)]
54pub struct MaxOutputTokensError;
55
56impl std::fmt::Display for MaxOutputTokensError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 write!(f, "output token limit reached")
59 }
60}
61
62impl std::error::Error for MaxOutputTokensError {}
63
64/// Key used in ACP ToolCall meta to store the tool's programmatic name.
65/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field.
66pub const TOOL_NAME_META_KEY: &str = "tool_name";
67
68/// Helper to extract tool name from ACP meta
69pub fn tool_name_from_meta(meta: &Option<acp::Meta>) -> Option<SharedString> {
70 meta.as_ref()
71 .and_then(|m| m.get(TOOL_NAME_META_KEY))
72 .and_then(|v| v.as_str())
73 .map(|s| SharedString::from(s.to_owned()))
74}
75
76/// Helper to create meta with tool name
77pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta {
78 acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())])
79}
80
81/// Key used in ACP `AvailableCommand` meta to record which source produced a
82/// slash command, so the completion popup can group commands by category.
83pub const COMMAND_CATEGORY_META_KEY: &str = "command_category";
84
85/// The source category of a slash command, used to group commands in the
86/// completion popup. Only the native Omega Agent executor annotates its commands; commands
87/// from external ACP agents carry no category and are grouped on their own.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum CommandCategory {
90 /// Built-in Omega Agent commands (e.g. `/compact`).
91 Native,
92 /// Commands sourced from MCP server prompts.
93 Mcp,
94}
95
96impl CommandCategory {
97 fn as_str(self) -> &'static str {
98 match self {
99 Self::Native => "native",
100 Self::Mcp => "mcp",
101 }
102 }
103
104 fn from_str(value: &str) -> Option<Self> {
105 match value {
106 "native" => Some(Self::Native),
107 "mcp" => Some(Self::Mcp),
108 _ => None,
109 }
110 }
111}
112
113pub fn meta_with_command_category(category: CommandCategory) -> acp::Meta {
114 acp::Meta::from_iter([(COMMAND_CATEGORY_META_KEY.into(), category.as_str().into())])
115}
116
117pub fn command_category_from_meta(meta: &Option<acp::Meta>) -> Option<CommandCategory> {
118 meta.as_ref()
119 .and_then(|m| m.get(COMMAND_CATEGORY_META_KEY))
120 .and_then(|v| v.as_str())
121 .and_then(CommandCategory::from_str)
122}
123
124/// Key used in ACP ToolCall meta to store the session id and message indexes
125pub const SUBAGENT_SESSION_INFO_META_KEY: &str = "subagent_session_info";
126
127pub const SANDBOX_AUTHORIZATION_META_KEY: &str = "sandbox_authorization";
128
129/// Stable `PermissionOption` ids for the sandbox-escalation approval prompt.
130///
131/// These are shared across the option construction (in the agent), the outcome
132/// dispatch, and the UI so the distinct grant lifetimes stay in sync. Note
133/// that `AllowThread` and `AllowAlways` both use
134/// `PermissionOptionKind::AllowAlways`; the id is what distinguishes them.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum SandboxPermission {
137 AllowOnce,
138 AllowThread,
139 AllowAlways,
140 Deny,
141}
142
143impl SandboxPermission {
144 pub fn as_id(self) -> &'static str {
145 match self {
146 Self::AllowOnce => "allow",
147 Self::AllowThread => "allow_thread",
148 Self::AllowAlways => "allow_always",
149 Self::Deny => "deny",
150 }
151 }
152
153 pub fn from_id(id: &str) -> Option<Self> {
154 match id {
155 "allow" => Some(Self::AllowOnce),
156 "allow_thread" => Some(Self::AllowThread),
157 "allow_always" => Some(Self::AllowAlways),
158 "deny" => Some(Self::Deny),
159 _ => None,
160 }
161 }
162}
163
164#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
165pub struct SandboxAuthorizationDetails {
166 #[serde(default)]
167 pub command: Option<String>,
168 /// Specific hosts the command requested network access to, in canonical
169 /// form (`github.com`, `*.npmjs.org`). Empty when no specific hosts were
170 /// requested (see `network_all_hosts`).
171 #[serde(default)]
172 pub network_hosts: Vec<String>,
173 /// Whether the command requested access to any host ("arbitrary network
174 /// access"). The `network` alias deserializes the field this replaced —
175 /// a plain bool meaning "network access" — so details persisted by older
176 /// builds still render the network request.
177 #[serde(default, alias = "network")]
178 pub network_all_hosts: bool,
179
180 #[serde(default)]
181 pub allow_fs_write_all: bool,
182 #[serde(default)]
183 pub unsandboxed: bool,
184 #[serde(default)]
185 pub write_paths: Vec<PathBuf>,
186 /// The agent-provided justification for requesting these permissions,
187 /// shown to the user (attributed to the agent) in the approval prompt.
188 #[serde(default)]
189 pub reason: String,
190}
191
192pub fn meta_with_sandbox_authorization(details: SandboxAuthorizationDetails) -> acp::Meta {
193 acp::Meta::from_iter([(
194 SANDBOX_AUTHORIZATION_META_KEY.into(),
195 serde_json::to_value(details).unwrap_or_default(),
196 )])
197}
198
199pub fn sandbox_authorization_details_from_meta(
200 meta: &Option<acp::Meta>,
201) -> Option<SandboxAuthorizationDetails> {
202 meta.as_ref()
203 .and_then(|m| m.get(SANDBOX_AUTHORIZATION_META_KEY))
204 .and_then(|v| serde_json::from_value(v.clone()).ok())
205}
206
207pub const SANDBOX_FALLBACK_AUTHORIZATION_META_KEY: &str = "sandbox_fallback_authorization";
208
209/// Stable `PermissionOption` id for the "Retry" choice in the sandbox
210/// *fallback* prompt (shown when the OS sandbox can't be created on this
211/// system). The remaining choices reuse the [`SandboxPermission`] ids.
212pub const SANDBOX_FALLBACK_RETRY_OPTION_ID: &str = "retry";
213
214/// Details shown when the OS sandbox could not be created for a command and
215/// the user is asked whether to run it without a sandbox. Distinct from
216/// [`SandboxAuthorizationDetails`] (a model-requested *escalation*): here the
217/// sandbox itself failed, so the prompt explains why and offers a retry.
218#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
219pub struct SandboxFallbackAuthorizationDetails {
220 #[serde(default)]
221 pub command: Option<String>,
222 /// Human-readable reason the OS sandbox could not be created (for example,
223 /// "bwrap not found on PATH"), shown to the user so they can decide
224 /// whether to run the command without a sandbox.
225 #[serde(default)]
226 pub reason: String,
227 /// Slug of the sandboxing docs section that best explains how to fix this
228 /// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a
229 /// "Learn more" link. `None` when the cause is unknown.
230 #[serde(default)]
231 pub docs_section: Option<String>,
232}
233
234pub fn meta_with_sandbox_fallback_authorization(
235 details: SandboxFallbackAuthorizationDetails,
236) -> acp::Meta {
237 acp::Meta::from_iter([(
238 SANDBOX_FALLBACK_AUTHORIZATION_META_KEY.into(),
239 serde_json::to_value(details).unwrap_or_default(),
240 )])
241}
242
243pub fn sandbox_fallback_authorization_details_from_meta(
244 meta: &Option<acp::Meta>,
245) -> Option<SandboxFallbackAuthorizationDetails> {
246 meta.as_ref()
247 .and_then(|m| m.get(SANDBOX_FALLBACK_AUTHORIZATION_META_KEY))
248 .and_then(|v| serde_json::from_value(v.clone()).ok())
249}
250
251/// Meta key recording why the OS sandbox was not applied to a terminal tool
252/// call, even though sandboxing was active for the thread. The value is a
253/// serialized [`SandboxNotAppliedReason`]. Surfaced as a warning in the UI and
254/// used to explain the situation to both the user and the agent.
255pub const SANDBOX_NOT_APPLIED_META_KEY: &str = "sandbox_not_applied";
256
257pub fn meta_with_sandbox_not_applied(reason: &SandboxNotAppliedReason) -> acp::Meta {
258 acp::Meta::from_iter([(
259 SANDBOX_NOT_APPLIED_META_KEY.into(),
260 serde_json::to_value(reason).unwrap_or_default(),
261 )])
262}
263
264pub fn sandbox_not_applied_from_meta(meta: &Option<acp::Meta>) -> Option<SandboxNotAppliedReason> {
265 meta.as_ref()
266 .and_then(|m| m.get(SANDBOX_NOT_APPLIED_META_KEY))
267 .and_then(|v| serde_json::from_value(v.clone()).ok())
268}
269
270#[derive(Clone, Debug, Deserialize, Serialize)]
271pub struct SubagentSessionInfo {
272 /// The session id of the subagent sessiont that was spawned
273 pub session_id: acp::SessionId,
274 /// The index of the message of the start of the "turn" run by this tool call
275 pub message_start_index: usize,
276 /// The index of the output of the message that the subagent has returned
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub message_end_index: Option<usize>,
279}
280
281/// Helper to extract subagent session id from ACP meta
282pub fn subagent_session_info_from_meta(meta: &Option<acp::Meta>) -> Option<SubagentSessionInfo> {
283 meta.as_ref()
284 .and_then(|m| m.get(SUBAGENT_SESSION_INFO_META_KEY))
285 .and_then(|v| serde_json::from_value(v.clone()).ok())
286}
287
288#[derive(Debug)]
289pub struct UserMessage {
290 pub protocol_id: Option<acp::MessageId>,
291 pub client_id: Option<ClientUserMessageId>,
292 pub is_optimistic: bool,
293 pub content: ContentBlock,
294 pub chunks: Vec<acp::ContentBlock>,
295 pub checkpoint: Option<Checkpoint>,
296 pub indented: bool,
297}
298
299#[derive(Debug)]
300pub struct Checkpoint {
301 git_checkpoint: GitStoreCheckpoint,
302 pub show: bool,
303}
304
305impl UserMessage {
306 fn to_markdown(&self, cx: &App) -> String {
307 let mut markdown = String::new();
308 if self
309 .checkpoint
310 .as_ref()
311 .is_some_and(|checkpoint| checkpoint.show)
312 {
313 writeln!(markdown, "## User (checkpoint)").unwrap();
314 } else {
315 writeln!(markdown, "## User").unwrap();
316 }
317 writeln!(markdown).unwrap();
318 writeln!(markdown, "{}", self.content.to_markdown(cx)).unwrap();
319 writeln!(markdown).unwrap();
320 markdown
321 }
322}
323
324#[derive(Debug, PartialEq)]
325pub struct AssistantMessage {
326 pub chunks: Vec<AssistantMessageChunk>,
327 pub indented: bool,
328 pub is_subagent_output: bool,
329}
330
331impl AssistantMessage {
332 pub fn to_markdown(&self, cx: &App) -> String {
333 format!(
334 "## Assistant\n\n{}\n\n",
335 self.chunks
336 .iter()
337 .map(|chunk| chunk.to_markdown(cx))
338 .join("\n\n")
339 )
340 }
341}
342
343#[derive(Debug, PartialEq)]
344pub enum AssistantMessageChunk {
345 Message {
346 id: Option<acp::MessageId>,
347 block: ContentBlock,
348 },
349 Thought {
350 id: Option<acp::MessageId>,
351 block: ContentBlock,
352 },
353}
354
355impl AssistantMessageChunk {
356 pub fn from_str(
357 chunk: &str,
358 language_registry: &Arc<LanguageRegistry>,
359 path_style: PathStyle,
360 cx: &mut App,
361 ) -> Self {
362 Self::Message {
363 id: None,
364 block: ContentBlock::new(chunk.into(), language_registry, path_style, cx),
365 }
366 }
367
368 fn to_markdown(&self, cx: &App) -> String {
369 match self {
370 Self::Message { block, .. } => block.to_markdown(cx).to_string(),
371 Self::Thought { block, .. } => {
372 format!("<thinking>\n{}\n</thinking>", block.to_markdown(cx))
373 }
374 }
375 }
376}
377
378fn can_merge_message_chunks(
379 existing: Option<&acp::MessageId>,
380 incoming: Option<&acp::MessageId>,
381) -> bool {
382 match (existing, incoming) {
383 (Some(existing), Some(incoming)) => existing == incoming,
384 _ => true,
385 }
386}
387
388#[derive(Debug)]
389pub enum AgentThreadEntry {
390 UserMessage(UserMessage),
391 AssistantMessage(AssistantMessage),
392 ToolCall(ToolCall),
393 Elicitation(ElicitationEntryId),
394 CompletedPlan(Vec<PlanEntry>),
395 ContextCompaction(ContextCompaction),
396 /// OMEGA-DELTA-0045. A note the supervising host wrote into the thread so
397 /// the owner reading it can see something that happened *to* the run
398 /// rather than inside a model turn.
399 SystemNote(SystemNote),
400}
401
402/// OMEGA-DELTA-0045. Identity of a host-authored system note.
403///
404/// The engine supplies it, so a retried `append_system_note` — after a host
405/// restart, or after a response the engine never saw — lands on the note that
406/// is already there instead of writing a second copy of the same disclosure.
407#[derive(Debug, Clone, PartialEq, Eq, Hash)]
408pub struct SystemNoteId(pub Arc<str>);
409
410/// OMEGA-DELTA-0045. A host-authored line in the thread.
411///
412/// This exists because of the rc11 defect: a cross-provider handoff changed
413/// which model was spending the owner's budget and left nothing in the thread
414/// the owner reads. `AgentThreadEntry` had no variant an owner-visible,
415/// non-model disclosure could be, so the host had nowhere to put one and
416/// refused instead. The refusal was honest; the missing seam was the defect.
417///
418/// The text is plain, not Markdown, and not model output: it is written by the
419/// host and rendered as-is, so nothing a provider emits can style, hide, or
420/// impersonate it.
421#[derive(Debug, Clone)]
422pub struct SystemNote {
423 pub id: SystemNoteId,
424 pub text: SharedString,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Hash)]
428pub struct ElicitationEntryId(pub Arc<str>);
429
430#[derive(Debug)]
431pub struct Elicitation {
432 pub id: ElicitationEntryId,
433 pub request: acp::CreateElicitationRequest,
434 pub status: ElicitationStatus,
435}
436
437#[derive(Debug)]
438pub enum ElicitationStatus {
439 Pending {
440 respond_tx: oneshot::Sender<acp::CreateElicitationResponse>,
441 },
442 Accepted,
443 Declined,
444 Canceled,
445 Completed,
446}
447
448#[derive(Clone, Debug)]
449pub enum ElicitationStoreEvent {
450 ElicitationRequested(ElicitationEntryId),
451 ElicitationResponded(ElicitationEntryId),
452 ElicitationUpdated(ElicitationEntryId),
453}
454
455#[derive(Default)]
456pub struct ElicitationStore {
457 elicitations: Vec<Elicitation>,
458}
459
460impl EventEmitter<ElicitationStoreEvent> for ElicitationStore {}
461
462impl ElicitationStore {
463 pub fn elicitations(&self) -> &[Elicitation] {
464 &self.elicitations
465 }
466
467 fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> {
468 if let acp::ElicitationMode::Url(mode) = &request.mode {
469 url::Url::parse(&mode.url)
470 .map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?;
471 }
472
473 Ok(())
474 }
475
476 fn insert_pending_elicitation(
477 &mut self,
478 request: acp::CreateElicitationRequest,
479 ) -> (
480 ElicitationEntryId,
481 oneshot::Receiver<acp::CreateElicitationResponse>,
482 ) {
483 let (respond_tx, response_rx) = oneshot::channel();
484 let id = ElicitationEntryId(Uuid::new_v4().to_string().into());
485 self.elicitations.push(Elicitation {
486 id: id.clone(),
487 request,
488 status: ElicitationStatus::Pending { respond_tx },
489 });
490 (id, response_rx)
491 }
492
493 fn response_task<T>(
494 id: ElicitationEntryId,
495 response_rx: oneshot::Receiver<acp::CreateElicitationResponse>,
496 cx: &mut Context<T>,
497 emit_responded: impl FnOnce(&mut T, &mut Context<T>, ElicitationEntryId) + 'static,
498 ) -> Task<acp::CreateElicitationResponse>
499 where
500 T: 'static,
501 {
502 cx.spawn(async move |this, cx| {
503 let response = response_rx.await.unwrap_or_else(|oneshot::Canceled| {
504 acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel)
505 });
506 this.update(cx, |this, cx| emit_responded(this, cx, id))
507 .ok();
508 response
509 })
510 }
511
512 fn respond_to_elicitation_entry(
513 elicitation: &mut Elicitation,
514 response: acp::CreateElicitationResponse,
515 ) -> bool {
516 if !matches!(elicitation.status, ElicitationStatus::Pending { .. }) {
517 return false;
518 }
519 let ElicitationStatus::Pending { respond_tx } = mem::replace(
520 &mut elicitation.status,
521 elicitation_status_for_response(&response),
522 ) else {
523 return false;
524 };
525 respond_tx.send(response).ok();
526 true
527 }
528
529 fn complete_url_elicitation_entry(elicitation: &mut Elicitation) -> bool {
530 let previous_status = mem::replace(&mut elicitation.status, ElicitationStatus::Completed);
531 match previous_status {
532 ElicitationStatus::Pending { respond_tx } => {
533 respond_tx
534 .send(acp::CreateElicitationResponse::new(
535 acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()),
536 ))
537 .ok();
538 true
539 }
540 ElicitationStatus::Accepted => true,
541 ElicitationStatus::Completed => false,
542 previous_status @ (ElicitationStatus::Declined | ElicitationStatus::Canceled) => {
543 elicitation.status = previous_status;
544 false
545 }
546 }
547 }
548
549 fn cancel_elicitation_entry(
550 elicitation: &mut Elicitation,
551 cancel_accepted_url_elicitations: bool,
552 ) -> bool {
553 match mem::replace(&mut elicitation.status, ElicitationStatus::Canceled) {
554 ElicitationStatus::Pending { respond_tx } => {
555 respond_tx
556 .send(acp::CreateElicitationResponse::new(
557 acp::ElicitationAction::Cancel,
558 ))
559 .ok();
560 true
561 }
562 ElicitationStatus::Accepted
563 if cancel_accepted_url_elicitations
564 && matches!(&elicitation.request.mode, acp::ElicitationMode::Url(_)) =>
565 {
566 true
567 }
568 previous_status => {
569 elicitation.status = previous_status;
570 false
571 }
572 }
573 }
574
575 fn respond_to_elicitation_by_id(
576 &mut self,
577 id: &ElicitationEntryId,
578 response: acp::CreateElicitationResponse,
579 ) -> bool {
580 let Some((_, elicitation)) = self.elicitation_mut(id) else {
581 return false;
582 };
583 Self::respond_to_elicitation_entry(elicitation, response)
584 }
585
586 fn complete_url_elicitation_by_id(&mut self, id: &ElicitationEntryId) -> bool {
587 let Some((_, elicitation)) = self.elicitation_mut(id) else {
588 return false;
589 };
590 Self::complete_url_elicitation_entry(elicitation)
591 }
592
593 fn cancel_elicitation_by_id(
594 &mut self,
595 id: &ElicitationEntryId,
596 cancel_accepted_url_elicitations: bool,
597 ) -> bool {
598 let Some((_, elicitation)) = self.elicitation_mut(id) else {
599 return false;
600 };
601 Self::cancel_elicitation_entry(elicitation, cancel_accepted_url_elicitations)
602 }
603
604 pub fn request_elicitation(
605 &mut self,
606 request: acp::CreateElicitationRequest,
607 cx: &mut Context<Self>,
608 ) -> Result<Task<acp::CreateElicitationResponse>, acp::Error> {
609 self.request_elicitation_with_id(request, cx)
610 .map(|(_, task)| task)
611 }
612
613 pub fn request_elicitation_with_id(
614 &mut self,
615 request: acp::CreateElicitationRequest,
616 cx: &mut Context<Self>,
617 ) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
618 Self::validate_request(&request)?;
619 let (id, response_rx) = self.insert_pending_elicitation(request);
620 cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone()));
621 cx.notify();
622
623 let task = Self::response_task(id.clone(), response_rx, cx, |_store, cx, id| {
624 cx.emit(ElicitationStoreEvent::ElicitationResponded(id));
625 cx.notify();
626 });
627
628 Ok((id, task))
629 }
630
631 pub fn respond_to_elicitation(
632 &mut self,
633 id: &ElicitationEntryId,
634 response: acp::CreateElicitationResponse,
635 cx: &mut Context<Self>,
636 ) {
637 if !self.respond_to_elicitation_by_id(id, response) {
638 return;
639 }
640
641 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
642 cx.notify();
643 }
644
645 pub fn complete_url_elicitation(
646 &mut self,
647 elicitation_id: &acp::ElicitationId,
648 cx: &mut Context<Self>,
649 ) {
650 let Some(entry_id) = self.entry_id_for_url_elicitation(elicitation_id) else {
651 return;
652 };
653 if !self.complete_url_elicitation_by_id(&entry_id) {
654 return;
655 }
656
657 cx.emit(ElicitationStoreEvent::ElicitationUpdated(entry_id));
658 cx.notify();
659 }
660
661 pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context<Self>) {
662 if !self.cancel_elicitation_by_id(id, true) {
663 return;
664 }
665
666 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
667 cx.notify();
668 }
669
670 pub fn cancel_all(&mut self, cx: &mut Context<Self>) {
671 let canceled_ids = self.cancel_pending(|_| true);
672 for id in canceled_ids {
673 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
674 }
675 cx.notify();
676 }
677
678 pub fn clear(&mut self, cx: &mut Context<Self>) {
679 let canceled_ids = self.cancel_pending(|_| true);
680 self.elicitations.clear();
681 for id in canceled_ids {
682 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
683 }
684 cx.notify();
685 }
686
687 pub fn clear_resolved(&mut self, cx: &mut Context<Self>) -> Vec<ElicitationEntryId> {
688 let mut cleared_ids = Vec::new();
689 self.elicitations.retain(|elicitation| {
690 let keep = matches!(
691 (&elicitation.status, &elicitation.request.mode),
692 (ElicitationStatus::Pending { .. }, _)
693 | (ElicitationStatus::Accepted, acp::ElicitationMode::Url(_))
694 );
695 if !keep {
696 cleared_ids.push(elicitation.id.clone());
697 }
698 keep
699 });
700
701 if !cleared_ids.is_empty() {
702 for id in &cleared_ids {
703 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id.clone()));
704 }
705 cx.notify();
706 }
707
708 cleared_ids
709 }
710
711 pub fn cancel_request(&mut self, request_id: &acp::RequestId, cx: &mut Context<Self>) {
712 let canceled_ids = self.cancel_pending(|elicitation| {
713 matches!(
714 elicitation.request.scope(),
715 acp::ElicitationScope::Request(scope) if &scope.request_id == request_id
716 )
717 });
718 for id in canceled_ids {
719 cx.emit(ElicitationStoreEvent::ElicitationUpdated(id));
720 }
721 cx.notify();
722 }
723
724 pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> {
725 self.elicitations
726 .iter()
727 .enumerate()
728 .rev()
729 .find_map(|(index, elicitation)| {
730 (&elicitation.id == id).then_some((index, elicitation))
731 })
732 }
733
734 fn entry_id_for_url_elicitation(
735 &self,
736 elicitation_id: &acp::ElicitationId,
737 ) -> Option<ElicitationEntryId> {
738 self.elicitations.iter().rev().find_map(|elicitation| {
739 if let acp::ElicitationMode::Url(mode) = &elicitation.request.mode
740 && &mode.elicitation_id == elicitation_id
741 {
742 Some(elicitation.id.clone())
743 } else {
744 None
745 }
746 })
747 }
748
749 fn elicitation_mut(&mut self, id: &ElicitationEntryId) -> Option<(usize, &mut Elicitation)> {
750 self.elicitations
751 .iter_mut()
752 .enumerate()
753 .rev()
754 .find_map(|(index, elicitation)| {
755 (&elicitation.id == id).then_some((index, elicitation))
756 })
757 }
758
759 fn cancel_pending(
760 &mut self,
761 mut should_cancel: impl FnMut(&Elicitation) -> bool,
762 ) -> Vec<ElicitationEntryId> {
763 let mut canceled_ids = Vec::new();
764 for elicitation in &mut self.elicitations {
765 if should_cancel(elicitation) && Self::cancel_elicitation_entry(elicitation, true) {
766 canceled_ids.push(elicitation.id.clone());
767 }
768 }
769 canceled_ids
770 }
771}
772
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub struct ContextCompactionId(pub Arc<str>);
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
777pub enum ContextCompactionStatus {
778 InProgress,
779 Completed,
780 Canceled,
781}
782
783/// A point in the thread where the conversation history was compacted to free
784/// up room in the model's context window. The summary can be expanded to inspect
785/// what the model retained.
786#[derive(Debug)]
787pub struct ContextCompaction {
788 pub id: ContextCompactionId,
789 pub status: ContextCompactionStatus,
790 /// The compaction summary, streamed in as the model produces it. This is
791 /// `None` for provider-native compaction, which produces no summary to show.
792 pub summary: Option<Entity<Markdown>>,
793}
794
795impl ContextCompaction {
796 pub fn is_in_progress(&self) -> bool {
797 self.status == ContextCompactionStatus::InProgress
798 }
799}
800
801#[derive(Debug)]
802pub struct ContextCompactionUpdate {
803 pub id: ContextCompactionId,
804 pub summary_delta: String,
805 pub status: Option<ContextCompactionStatus>,
806}
807
808impl AgentThreadEntry {
809 pub fn is_indented(&self) -> bool {
810 match self {
811 Self::UserMessage(message) => message.indented,
812 Self::AssistantMessage(message) => message.indented,
813 Self::ToolCall(_) => false,
814 Self::Elicitation(_) => false,
815 Self::CompletedPlan(_) => false,
816 Self::ContextCompaction(_) => false,
817 Self::SystemNote(_) => false,
818 }
819 }
820
821 pub fn to_markdown(&self, cx: &App) -> String {
822 match self {
823 Self::UserMessage(message) => message.to_markdown(cx),
824 Self::AssistantMessage(message) => message.to_markdown(cx),
825 Self::ToolCall(tool_call) => tool_call.to_markdown(cx),
826 Self::Elicitation(_) => "## Input Requested\n\n".to_string(),
827 Self::CompletedPlan(entries) => {
828 let mut md = String::from("## Plan\n\n");
829 for entry in entries {
830 let source = entry.content.read(cx).source().to_string();
831 md.push_str(&format!("- [x] {}\n", source));
832 }
833 md
834 }
835 Self::ContextCompaction(_) => "--- Context Compacted ---\n\n".to_string(),
836 Self::SystemNote(note) => format!("--- {} ---\n\n", note.text),
837 }
838 }
839
840 pub fn user_message(&self) -> Option<&UserMessage> {
841 if let AgentThreadEntry::UserMessage(message) = self {
842 Some(message)
843 } else {
844 None
845 }
846 }
847
848 pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
849 if let AgentThreadEntry::ToolCall(call) = self {
850 itertools::Either::Left(call.diffs())
851 } else {
852 itertools::Either::Right(std::iter::empty())
853 }
854 }
855
856 pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
857 if let AgentThreadEntry::ToolCall(call) = self {
858 itertools::Either::Left(call.terminals())
859 } else {
860 itertools::Either::Right(std::iter::empty())
861 }
862 }
863
864 pub fn location(&self, ix: usize) -> Option<(acp::ToolCallLocation, AgentLocation)> {
865 if let AgentThreadEntry::ToolCall(ToolCall {
866 locations,
867 resolved_locations,
868 ..
869 }) = self
870 {
871 Some((
872 locations.get(ix)?.clone(),
873 resolved_locations.get(ix)?.clone()?,
874 ))
875 } else {
876 None
877 }
878 }
879}
880
881#[derive(Debug)]
882pub struct ToolCall {
883 pub id: acp::ToolCallId,
884 pub label: Entity<Markdown>,
885 pub kind: acp::ToolKind,
886 pub content: Vec<ToolCallContent>,
887 pub status: ToolCallStatus,
888 pub locations: Vec<acp::ToolCallLocation>,
889 pub resolved_locations: Vec<Option<AgentLocation>>,
890 pub raw_input: Option<serde_json::Value>,
891 pub raw_input_markdown: Option<Entity<Markdown>>,
892 pub raw_output: Option<serde_json::Value>,
893 pub tool_name: Option<SharedString>,
894 pub subagent_session_info: Option<SubagentSessionInfo>,
895 pub sandbox_authorization_details: Option<SandboxAuthorizationDetails>,
896 pub sandbox_fallback_authorization_details: Option<SandboxFallbackAuthorizationDetails>,
897 /// Why this terminal command ran without the OS sandbox even though
898 /// sandboxing was active (see [`SANDBOX_NOT_APPLIED_META_KEY`]). `None` when
899 /// the command was sandboxed normally (or sandboxing was off).
900 pub sandbox_not_applied: Option<SandboxNotAppliedReason>,
901}
902
903impl ToolCall {
904 fn from_acp(
905 tool_call: acp::ToolCall,
906 status: ToolCallStatus,
907 language_registry: Arc<LanguageRegistry>,
908 path_style: PathStyle,
909 terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
910 cx: &mut App,
911 ) -> Result<Self> {
912 let title = if tool_call.kind == acp::ToolKind::Execute {
913 tool_call.title
914 } else if tool_call.kind == acp::ToolKind::Edit {
915 MarkdownEscaped(tool_call.title.as_str()).to_string()
916 } else if let Some((first_line, _)) = tool_call.title.split_once("\n") {
917 first_line.to_owned() + "…"
918 } else {
919 tool_call.title
920 };
921 let mut content = Vec::with_capacity(tool_call.content.len());
922 for item in tool_call.content {
923 if let Some(item) = ToolCallContent::from_acp(
924 item,
925 language_registry.clone(),
926 path_style,
927 terminals,
928 cx,
929 )? {
930 content.push(item);
931 }
932 }
933
934 let raw_input_markdown = tool_call
935 .raw_input
936 .as_ref()
937 .and_then(|input| markdown_for_raw_output(input, &language_registry, cx));
938
939 let tool_name = tool_name_from_meta(&tool_call.meta);
940
941 let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta);
942 let sandbox_authorization_details =
943 sandbox_authorization_details_from_meta(&tool_call.meta);
944 let sandbox_fallback_authorization_details =
945 sandbox_fallback_authorization_details_from_meta(&tool_call.meta);
946 let sandbox_not_applied = sandbox_not_applied_from_meta(&tool_call.meta);
947
948 let label = if tool_call.kind == acp::ToolKind::Execute {
949 cx.new(|cx| Markdown::new_text(title.into(), cx))
950 } else {
951 cx.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx))
952 };
953
954 let result = Self {
955 id: tool_call.tool_call_id,
956 label,
957 kind: tool_call.kind,
958 content,
959 locations: tool_call.locations,
960 resolved_locations: Vec::default(),
961 status,
962 raw_input: tool_call.raw_input,
963 raw_input_markdown,
964 raw_output: tool_call.raw_output,
965 tool_name,
966 subagent_session_info,
967 sandbox_authorization_details,
968 sandbox_fallback_authorization_details,
969 sandbox_not_applied,
970 };
971 Ok(result)
972 }
973
974 fn update_fields(
975 &mut self,
976 fields: acp::ToolCallUpdateFields,
977 meta: Option<acp::Meta>,
978 language_registry: Arc<LanguageRegistry>,
979 path_style: PathStyle,
980 terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
981 cx: &mut App,
982 ) -> Result<()> {
983 let acp::ToolCallUpdateFields {
984 kind,
985 status,
986 title,
987 content,
988 locations,
989 raw_input,
990 raw_output,
991 ..
992 } = fields;
993
994 if let Some(kind) = kind {
995 self.kind = kind;
996 }
997
998 if let Some(status) = status {
999 self.update_acp_status(status);
1000 }
1001
1002 if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) {
1003 self.subagent_session_info = Some(subagent_session_info);
1004 }
1005 if let Some(sandbox_authorization_details) = sandbox_authorization_details_from_meta(&meta)
1006 {
1007 self.sandbox_authorization_details = Some(sandbox_authorization_details);
1008 }
1009 if let Some(sandbox_fallback_authorization_details) =
1010 sandbox_fallback_authorization_details_from_meta(&meta)
1011 {
1012 self.sandbox_fallback_authorization_details =
1013 Some(sandbox_fallback_authorization_details);
1014 }
1015 if let Some(sandbox_not_applied) = sandbox_not_applied_from_meta(&meta) {
1016 self.sandbox_not_applied = Some(sandbox_not_applied);
1017 }
1018
1019 if let Some(title) = title {
1020 if self.kind == acp::ToolKind::Execute {
1021 for terminal in self.terminals() {
1022 terminal.update(cx, |terminal, cx| {
1023 terminal.update_command_label(&title, cx);
1024 });
1025 }
1026 }
1027 self.label.update(cx, |label, cx| {
1028 if self.kind == acp::ToolKind::Execute {
1029 label.replace(title, cx);
1030 } else if self.kind == acp::ToolKind::Edit {
1031 label.replace(MarkdownEscaped(&title).to_string(), cx)
1032 } else if let Some((first_line, _)) = title.split_once("\n") {
1033 label.replace(first_line.to_owned() + "…", cx);
1034 } else {
1035 label.replace(title, cx);
1036 }
1037 });
1038 }
1039
1040 if let Some(content) = content {
1041 let mut new_content_len = content.len();
1042 let mut content = content.into_iter();
1043
1044 // Reuse existing content if we can
1045 for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
1046 let valid_content =
1047 old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?;
1048 if !valid_content {
1049 new_content_len -= 1;
1050 }
1051 }
1052 for new in content {
1053 if let Some(new) = ToolCallContent::from_acp(
1054 new,
1055 language_registry.clone(),
1056 path_style,
1057 terminals,
1058 cx,
1059 )? {
1060 self.content.push(new);
1061 } else {
1062 new_content_len -= 1;
1063 }
1064 }
1065 self.content.truncate(new_content_len);
1066 }
1067
1068 if let Some(locations) = locations {
1069 self.locations = locations;
1070 }
1071
1072 if let Some(raw_input) = raw_input {
1073 self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx);
1074 self.raw_input = Some(raw_input);
1075 }
1076
1077 if let Some(raw_output) = raw_output {
1078 if self.content.is_empty()
1079 && let Some(markdown) = markdown_for_raw_output(&raw_output, &language_registry, cx)
1080 {
1081 self.content
1082 .push(ToolCallContent::ContentBlock(ContentBlock::Markdown {
1083 markdown,
1084 }));
1085 }
1086 self.raw_output = Some(raw_output);
1087 }
1088 Ok(())
1089 }
1090
1091 fn update_status(&mut self, status: ToolCallStatus) {
1092 match status {
1093 ToolCallStatus::Pending => self.update_acp_status(acp::ToolCallStatus::Pending),
1094 ToolCallStatus::InProgress => self.update_acp_status(acp::ToolCallStatus::InProgress),
1095 ToolCallStatus::Completed => self.update_acp_status(acp::ToolCallStatus::Completed),
1096 ToolCallStatus::Failed => self.update_acp_status(acp::ToolCallStatus::Failed),
1097 status @ (ToolCallStatus::WaitingForConfirmation { .. }
1098 | ToolCallStatus::Rejected
1099 | ToolCallStatus::Canceled) => self.status = status,
1100 }
1101 }
1102
1103 fn update_acp_status(&mut self, status: acp::ToolCallStatus) {
1104 if let ToolCallStatus::WaitingForConfirmation { current_status, .. } = &mut self.status
1105 && matches!(
1106 status,
1107 acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress
1108 )
1109 {
1110 *current_status = status;
1111 } else {
1112 self.status = status.into();
1113 }
1114 }
1115
1116 pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
1117 self.content.iter().filter_map(|content| match content {
1118 ToolCallContent::Diff(diff) => Some(diff),
1119 ToolCallContent::ContentBlock(_) => None,
1120 ToolCallContent::Terminal(_) => None,
1121 })
1122 }
1123
1124 pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
1125 self.content.iter().filter_map(|content| match content {
1126 ToolCallContent::Terminal(terminal) => Some(terminal),
1127 ToolCallContent::ContentBlock(_) => None,
1128 ToolCallContent::Diff(_) => None,
1129 })
1130 }
1131
1132 pub fn is_subagent(&self) -> bool {
1133 self.tool_name.as_ref().is_some_and(|s| s == "spawn_agent")
1134 || self.subagent_session_info.is_some()
1135 }
1136
1137 pub fn to_markdown(&self, cx: &App) -> String {
1138 let mut markdown = format!(
1139 "**Tool Call: {}**\nStatus: {}\n\n",
1140 self.label.read(cx).source(),
1141 self.status
1142 );
1143 for content in &self.content {
1144 markdown.push_str(content.to_markdown(cx).as_str());
1145 markdown.push_str("\n\n");
1146 }
1147 markdown
1148 }
1149
1150 async fn resolve_location(
1151 location: acp::ToolCallLocation,
1152 project: WeakEntity<Project>,
1153 cx: &mut AsyncApp,
1154 ) -> Option<ResolvedLocation> {
1155 let buffer = project
1156 .update(cx, |project, cx| {
1157 if let Some(path) = project.project_path_for_absolute_path(&location.path, cx) {
1158 Some(project.open_buffer(path, cx))
1159 } else if is_absolute(
1160 location.path.to_string_lossy().as_ref(),
1161 project.path_style(cx),
1162 ) {
1163 Some(project.open_local_buffer(&location.path, cx))
1164 } else {
1165 None
1166 }
1167 })
1168 .ok()??;
1169 let buffer = buffer.await.log_err()?;
1170 let position = buffer.update(cx, |buffer, _| {
1171 let snapshot = buffer.snapshot();
1172 if let Some(row) = location.line {
1173 let column = snapshot.indent_size_for_line(row).len;
1174 let point = snapshot.clip_point(Point::new(row, column), Bias::Left);
1175 snapshot.anchor_before(point)
1176 } else {
1177 Anchor::min_for_buffer(snapshot.remote_id())
1178 }
1179 });
1180
1181 Some(ResolvedLocation { buffer, position })
1182 }
1183
1184 fn resolve_locations(
1185 &self,
1186 project: Entity<Project>,
1187 cx: &mut App,
1188 ) -> Task<Vec<Option<ResolvedLocation>>> {
1189 let locations = self.locations.clone();
1190 project.update(cx, |_, cx| {
1191 cx.spawn(async move |project, cx| {
1192 let mut new_locations = Vec::new();
1193 for location in locations {
1194 new_locations.push(Self::resolve_location(location, project.clone(), cx).await);
1195 }
1196 new_locations
1197 })
1198 })
1199 }
1200}
1201
1202// Separate so we can hold a strong reference to the buffer
1203// for saving on the thread
1204#[derive(Clone, Debug, PartialEq, Eq)]
1205struct ResolvedLocation {
1206 buffer: Entity<Buffer>,
1207 position: Anchor,
1208}
1209
1210impl From<&ResolvedLocation> for AgentLocation {
1211 fn from(value: &ResolvedLocation) -> Self {
1212 Self {
1213 buffer: value.buffer.downgrade(),
1214 position: value.position,
1215 }
1216 }
1217}
1218
1219#[derive(Debug, Clone)]
1220pub enum SelectedPermissionParams {
1221 Terminal { patterns: Vec<String> },
1222}
1223
1224#[derive(Debug, Clone)]
1225pub struct SelectedPermissionOutcome {
1226 pub option_id: acp::PermissionOptionId,
1227 pub option_kind: acp::PermissionOptionKind,
1228 pub params: Option<SelectedPermissionParams>,
1229}
1230
1231impl SelectedPermissionOutcome {
1232 pub fn new(option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind) -> Self {
1233 Self {
1234 option_id,
1235 option_kind,
1236 params: None,
1237 }
1238 }
1239
1240 pub fn params(mut self, params: Option<SelectedPermissionParams>) -> Self {
1241 self.params = params;
1242 self
1243 }
1244}
1245
1246impl From<SelectedPermissionOutcome> for acp::SelectedPermissionOutcome {
1247 fn from(value: SelectedPermissionOutcome) -> Self {
1248 Self::new(value.option_id)
1249 }
1250}
1251
1252#[derive(Debug)]
1253pub enum RequestPermissionOutcome {
1254 Cancelled,
1255 Selected(SelectedPermissionOutcome),
1256}
1257
1258impl From<RequestPermissionOutcome> for acp::RequestPermissionOutcome {
1259 fn from(value: RequestPermissionOutcome) -> Self {
1260 match value {
1261 RequestPermissionOutcome::Cancelled => Self::Cancelled,
1262 RequestPermissionOutcome::Selected(outcome) => Self::Selected(outcome.into()),
1263 }
1264 }
1265}
1266
1267/// What a `WaitingForConfirmation` prompt represents semantically.
1268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1269pub enum AuthorizationKind {
1270 /// The user is granting or denying permission for the tool call to
1271 /// proceed. The selected `PermissionOptionKind` determines whether the
1272 /// tool call transitions to `InProgress` (allow) or `Rejected` (reject).
1273 /// This is the default for tool authorization prompts.
1274 PermissionGrant,
1275 /// The user is choosing between actions for the tool to take next
1276 /// (for example, "Save" vs "Discard" before editing a dirty buffer).
1277 /// The tool call always transitions to `InProgress` regardless of the
1278 /// selected `PermissionOptionKind`; the caller interprets the chosen
1279 /// `option_id` to decide what to do.
1280 ActionChoice,
1281}
1282
1283#[derive(Debug)]
1284pub enum ToolCallStatus {
1285 /// The tool call hasn't started running yet, but we start showing it to
1286 /// the user.
1287 Pending,
1288 /// The tool call is waiting for confirmation from the user.
1289 WaitingForConfirmation {
1290 current_status: acp::ToolCallStatus,
1291 options: PermissionOptions,
1292 respond_tx: oneshot::Sender<SelectedPermissionOutcome>,
1293 kind: AuthorizationKind,
1294 },
1295 /// The tool call is currently running.
1296 InProgress,
1297 /// The tool call completed successfully.
1298 Completed,
1299 /// The tool call failed.
1300 Failed,
1301 /// The user rejected the tool call.
1302 Rejected,
1303 /// The user canceled generation so the tool call was canceled.
1304 Canceled,
1305}
1306
1307impl From<acp::ToolCallStatus> for ToolCallStatus {
1308 fn from(status: acp::ToolCallStatus) -> Self {
1309 match status {
1310 acp::ToolCallStatus::Pending => Self::Pending,
1311 acp::ToolCallStatus::InProgress => Self::InProgress,
1312 acp::ToolCallStatus::Completed => Self::Completed,
1313 acp::ToolCallStatus::Failed => Self::Failed,
1314 _ => Self::Pending,
1315 }
1316 }
1317}
1318
1319impl ToolCallStatus {
1320 fn as_acp_status(&self) -> Option<acp::ToolCallStatus> {
1321 match self {
1322 ToolCallStatus::Pending => Some(acp::ToolCallStatus::Pending),
1323 ToolCallStatus::WaitingForConfirmation { current_status, .. } => Some(*current_status),
1324 ToolCallStatus::InProgress => Some(acp::ToolCallStatus::InProgress),
1325 ToolCallStatus::Completed => Some(acp::ToolCallStatus::Completed),
1326 ToolCallStatus::Failed => Some(acp::ToolCallStatus::Failed),
1327 ToolCallStatus::Rejected | ToolCallStatus::Canceled => None,
1328 }
1329 }
1330
1331 fn status_after_permission_grant(status: acp::ToolCallStatus) -> ToolCallStatus {
1332 match ToolCallStatus::from(status) {
1333 ToolCallStatus::Pending => ToolCallStatus::InProgress,
1334 status => status,
1335 }
1336 }
1337}
1338
1339impl Display for ToolCallStatus {
1340 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1341 write!(
1342 f,
1343 "{}",
1344 match self {
1345 ToolCallStatus::Pending => "Pending",
1346 ToolCallStatus::WaitingForConfirmation { .. } => "Waiting for confirmation",
1347 ToolCallStatus::InProgress => "In Progress",
1348 ToolCallStatus::Completed => "Completed",
1349 ToolCallStatus::Failed => "Failed",
1350 ToolCallStatus::Rejected => "Rejected",
1351 ToolCallStatus::Canceled => "Canceled",
1352 }
1353 )
1354 }
1355}
1356
1357fn elicitation_status_for_response(response: &acp::CreateElicitationResponse) -> ElicitationStatus {
1358 match &response.action {
1359 acp::ElicitationAction::Accept(_) => ElicitationStatus::Accepted,
1360 acp::ElicitationAction::Decline => ElicitationStatus::Declined,
1361 acp::ElicitationAction::Cancel => ElicitationStatus::Canceled,
1362 _ => ElicitationStatus::Canceled,
1363 }
1364}
1365
1366#[derive(Debug, PartialEq, Clone)]
1367pub enum ContentBlock {
1368 Empty,
1369 Markdown {
1370 markdown: Entity<Markdown>,
1371 },
1372 EmbeddedResource {
1373 resource: acp::EmbeddedResource,
1374 markdown: Option<Entity<Markdown>>,
1375 },
1376 ResourceLink {
1377 resource_link: acp::ResourceLink,
1378 },
1379 Image {
1380 image: Arc<gpui::Image>,
1381 dimensions: Option<gpui::Size<u32>>,
1382 },
1383}
1384
1385impl ContentBlock {
1386 pub fn new(
1387 block: acp::ContentBlock,
1388 language_registry: &Arc<LanguageRegistry>,
1389 path_style: PathStyle,
1390 cx: &mut App,
1391 ) -> Self {
1392 let mut this = Self::Empty;
1393 this.append(block, language_registry, path_style, cx);
1394 this
1395 }
1396
1397 pub fn new_combined(
1398 blocks: impl IntoIterator<Item = acp::ContentBlock>,
1399 language_registry: Arc<LanguageRegistry>,
1400 path_style: PathStyle,
1401 cx: &mut App,
1402 ) -> Self {
1403 let mut this = Self::Empty;
1404 for block in blocks {
1405 this.append(block, &language_registry, path_style, cx);
1406 }
1407 this
1408 }
1409
1410 pub fn new_tool_call_content(
1411 block: acp::ContentBlock,
1412 language_registry: &Arc<LanguageRegistry>,
1413 path_style: PathStyle,
1414 cx: &mut App,
1415 ) -> Self {
1416 match block {
1417 acp::ContentBlock::Resource(resource) => {
1418 if let Some((image, dimensions)) = Self::decode_embedded_resource_image(&resource) {
1419 Self::Image { image, dimensions }
1420 } else {
1421 let markdown =
1422 Self::embedded_resource_markdown(&resource, language_registry, cx);
1423 Self::EmbeddedResource { resource, markdown }
1424 }
1425 }
1426 block => Self::new(block, language_registry, path_style, cx),
1427 }
1428 }
1429
1430 pub fn append(
1431 &mut self,
1432 block: acp::ContentBlock,
1433 language_registry: &Arc<LanguageRegistry>,
1434 path_style: PathStyle,
1435 cx: &mut App,
1436 ) {
1437 match (&mut *self, &block) {
1438 (ContentBlock::Empty, acp::ContentBlock::ResourceLink(resource_link)) => {
1439 *self = ContentBlock::ResourceLink {
1440 resource_link: resource_link.clone(),
1441 };
1442 }
1443 (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => {
1444 if let Some((image, dimensions)) = Self::decode_image(image_content) {
1445 *self = ContentBlock::Image { image, dimensions };
1446 } else {
1447 let new_content = Self::image_md(image_content);
1448 *self = Self::create_markdown_block(new_content, language_registry, cx);
1449 }
1450 }
1451 (ContentBlock::Empty, _) => {
1452 let new_content = Self::block_string_contents(&block, path_style);
1453 *self = Self::create_markdown_block(new_content, language_registry, cx);
1454 }
1455 (ContentBlock::Markdown { markdown }, _) => {
1456 let new_content = Self::block_string_contents(&block, path_style);
1457 markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
1458 }
1459 (ContentBlock::ResourceLink { resource_link }, _) => {
1460 let existing_content = Self::resource_link_md(&resource_link.uri, path_style);
1461 let new_content = Self::block_string_contents(&block, path_style);
1462 let combined = format!("{}\n{}", existing_content, new_content);
1463 *self = Self::create_markdown_block(combined, language_registry, cx);
1464 }
1465 (ContentBlock::EmbeddedResource { resource, .. }, _) => {
1466 let existing_content =
1467 Self::embedded_resource_string_contents(resource, path_style);
1468 let new_content = Self::block_string_contents(&block, path_style);
1469 let combined = format!("{}\n{}", existing_content, new_content);
1470 *self = Self::create_markdown_block(combined, language_registry, cx);
1471 }
1472 (ContentBlock::Image { .. }, _) => {
1473 let new_content = Self::block_string_contents(&block, path_style);
1474 let combined = format!("`Image`\n{}", new_content);
1475 *self = Self::create_markdown_block(combined, language_registry, cx);
1476 }
1477 }
1478 }
1479
1480 /// Updates a Markdown block in place from a streaming text `block`, reusing
1481 /// the existing `Markdown` entity rather than recreating it. Appends only the
1482 /// new suffix when the update is a continuation (the common streaming case),
1483 /// otherwise re-sets the source. Returns `false` when an in-place update isn't
1484 /// applicable, so the caller can fall back to replacing the block wholesale.
1485 ///
1486 /// Recreating the entity on every streamed snapshot causes the rendered
1487 /// element to tear down and rebuild, which flickers badly.
1488 pub fn update_text_in_place(&mut self, block: &acp::ContentBlock, cx: &mut App) -> bool {
1489 let ContentBlock::Markdown { markdown } = self else {
1490 return false;
1491 };
1492 let acp::ContentBlock::Text(text_content) = block else {
1493 return false;
1494 };
1495 let new_content = &text_content.text;
1496 markdown.update(cx, |markdown, cx| {
1497 let current = markdown.source().to_string();
1498 match new_content.strip_prefix(¤t) {
1499 Some("") => {}
1500 Some(suffix) => markdown.append(suffix, cx),
1501 None => markdown.reset(new_content.clone().into(), cx),
1502 }
1503 });
1504 true
1505 }
1506
1507 fn decode_image(
1508 image_content: &acp::ImageContent,
1509 ) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
1510 Self::decode_image_data(&image_content.data, &image_content.mime_type)
1511 }
1512
1513 fn decode_embedded_resource_image(
1514 resource: &acp::EmbeddedResource,
1515 ) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
1516 let acp::EmbeddedResourceResource::BlobResourceContents(blob) = &resource.resource else {
1517 return None;
1518 };
1519 let mime_type = blob.mime_type.as_deref()?;
1520 Self::decode_image_data(&blob.blob, mime_type)
1521 }
1522
1523 fn decode_image_data(
1524 data: &str,
1525 mime_type: &str,
1526 ) -> Option<(Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
1527 use base64::Engine as _;
1528
1529 let bytes = base64::engine::general_purpose::STANDARD
1530 .decode(data.as_bytes())
1531 .ok()?;
1532 let format = gpui::ImageFormat::from_mime_type(mime_type)?;
1533 let dimensions = Self::image_dimensions(&bytes, format);
1534 Some((Arc::new(gpui::Image::from_bytes(format, bytes)), dimensions))
1535 }
1536
1537 fn image_dimensions(bytes: &[u8], format: gpui::ImageFormat) -> Option<gpui::Size<u32>> {
1538 let format = match format {
1539 gpui::ImageFormat::Png => image::ImageFormat::Png,
1540 gpui::ImageFormat::Jpeg => image::ImageFormat::Jpeg,
1541 gpui::ImageFormat::Webp => image::ImageFormat::WebP,
1542 gpui::ImageFormat::Gif => image::ImageFormat::Gif,
1543 gpui::ImageFormat::Svg => return None,
1544 gpui::ImageFormat::Bmp => image::ImageFormat::Bmp,
1545 gpui::ImageFormat::Tiff => image::ImageFormat::Tiff,
1546 gpui::ImageFormat::Ico => image::ImageFormat::Ico,
1547 gpui::ImageFormat::Pnm => image::ImageFormat::Pnm,
1548 };
1549
1550 image::ImageReader::with_format(std::io::Cursor::new(bytes), format)
1551 .into_dimensions()
1552 .ok()
1553 .map(|(width, height)| gpui::Size { width, height })
1554 }
1555
1556 fn create_markdown_block(
1557 content: String,
1558 language_registry: &Arc<LanguageRegistry>,
1559 cx: &mut App,
1560 ) -> ContentBlock {
1561 ContentBlock::Markdown {
1562 markdown: Self::create_markdown(content, language_registry, cx),
1563 }
1564 }
1565
1566 fn create_markdown(
1567 content: String,
1568 language_registry: &Arc<LanguageRegistry>,
1569 cx: &mut App,
1570 ) -> Entity<Markdown> {
1571 cx.new(|cx| {
1572 Markdown::new_with_options(
1573 content.into(),
1574 Some(language_registry.clone()),
1575 None,
1576 MarkdownOptions {
1577 render_mermaid_diagrams: true,
1578 render_metadata_blocks: true,
1579 ..Default::default()
1580 },
1581 cx,
1582 )
1583 })
1584 }
1585
1586 fn embedded_resource_markdown(
1587 resource: &acp::EmbeddedResource,
1588 language_registry: &Arc<LanguageRegistry>,
1589 cx: &mut App,
1590 ) -> Option<Entity<Markdown>> {
1591 match &resource.resource {
1592 acp::EmbeddedResourceResource::TextResourceContents(text) => Some(
1593 Self::create_markdown(Self::text_resource_markdown(text), language_registry, cx),
1594 ),
1595 acp::EmbeddedResourceResource::BlobResourceContents(_) => None,
1596 _ => None,
1597 }
1598 }
1599
1600 fn text_resource_markdown(resource: &acp::TextResourceContents) -> String {
1601 match text_resource_render_mode(resource.mime_type.as_deref()) {
1602 TextResourceRenderMode::Markdown => resource.text.clone(),
1603 TextResourceRenderMode::CodeBlock(language) => {
1604 Self::fenced_code_block(&resource.text, language)
1605 }
1606 }
1607 }
1608
1609 pub fn text_content<'a>(&'a self, cx: &'a App) -> Option<&'a str> {
1610 match self {
1611 ContentBlock::Markdown { markdown } => Some(markdown.read(cx).source()),
1612 ContentBlock::EmbeddedResource { resource, .. } => match &resource.resource {
1613 acp::EmbeddedResourceResource::TextResourceContents(text) => Some(&text.text),
1614 acp::EmbeddedResourceResource::BlobResourceContents(_) => None,
1615 _ => None,
1616 },
1617 ContentBlock::Empty
1618 | ContentBlock::ResourceLink { .. }
1619 | ContentBlock::Image { .. } => None,
1620 }
1621 }
1622
1623 fn fenced_code_block(text: &str, language: Option<&str>) -> String {
1624 let fence_len = text
1625 .as_bytes()
1626 .chunk_by(|left, right| left == right)
1627 .filter(|chunk| chunk.first() == Some(&b'`'))
1628 .map(|chunk| chunk.len() + 1)
1629 .max()
1630 .unwrap_or(3)
1631 .max(3);
1632 let fence = "`".repeat(fence_len);
1633
1634 let mut markdown = String::new();
1635 markdown.push_str(&fence);
1636 if let Some(language) = language {
1637 markdown.push_str(language);
1638 }
1639 markdown.push('\n');
1640 markdown.push_str(text);
1641 if !text.ends_with('\n') {
1642 markdown.push('\n');
1643 }
1644 markdown.push_str(&fence);
1645 markdown
1646 }
1647
1648 fn embedded_resource_string_contents(
1649 resource: &acp::EmbeddedResource,
1650 path_style: PathStyle,
1651 ) -> String {
1652 match &resource.resource {
1653 acp::EmbeddedResourceResource::TextResourceContents(text) => {
1654 Self::resource_link_md(&text.uri, path_style)
1655 }
1656 acp::EmbeddedResourceResource::BlobResourceContents(blob) => {
1657 Self::resource_link_md(&blob.uri, path_style)
1658 }
1659 _ => String::new(),
1660 }
1661 }
1662
1663 fn embedded_resource_text(resource: &acp::EmbeddedResource) -> &str {
1664 match &resource.resource {
1665 acp::EmbeddedResourceResource::TextResourceContents(text) => &text.text,
1666 acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri,
1667 _ => "",
1668 }
1669 }
1670
1671 fn embedded_resource_label(resource: &acp::EmbeddedResource) -> &str {
1672 match &resource.resource {
1673 acp::EmbeddedResourceResource::TextResourceContents(text) => &text.uri,
1674 acp::EmbeddedResourceResource::BlobResourceContents(blob) => &blob.uri,
1675 _ => "",
1676 }
1677 }
1678
1679 pub fn embedded_resource(&self) -> Option<(&acp::EmbeddedResource, Option<&Entity<Markdown>>)> {
1680 match self {
1681 ContentBlock::EmbeddedResource { resource, markdown } => {
1682 Some((resource, markdown.as_ref()))
1683 }
1684 _ => None,
1685 }
1686 }
1687
1688 pub fn visible_content(&self, cx: &App) -> bool {
1689 match self {
1690 ContentBlock::Empty => false,
1691 ContentBlock::Markdown { markdown } => !markdown.read(cx).source().trim().is_empty(),
1692 ContentBlock::EmbeddedResource { resource, markdown } => match markdown {
1693 Some(markdown) => !markdown.read(cx).source().trim().is_empty(),
1694 None => !Self::embedded_resource_text(resource).trim().is_empty(),
1695 },
1696 ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => true,
1697 }
1698 }
1699
1700 fn block_string_contents(block: &acp::ContentBlock, path_style: PathStyle) -> String {
1701 match block {
1702 acp::ContentBlock::Text(text_content) => text_content.text.clone(),
1703 acp::ContentBlock::ResourceLink(resource_link) => {
1704 Self::resource_link_md(&resource_link.uri, path_style)
1705 }
1706 acp::ContentBlock::Resource(acp::EmbeddedResource {
1707 resource:
1708 acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents {
1709 uri,
1710 ..
1711 }),
1712 ..
1713 }) => Self::resource_link_md(uri, path_style),
1714 acp::ContentBlock::Image(image) => Self::image_md(image),
1715 _ => String::new(),
1716 }
1717 }
1718
1719 fn resource_link_md(uri: &str, path_style: PathStyle) -> String {
1720 if let Some(uri) = MentionUri::parse(uri, path_style).log_err() {
1721 uri.as_link().to_string()
1722 } else {
1723 uri.to_string()
1724 }
1725 }
1726
1727 fn image_md(_image: &acp::ImageContent) -> String {
1728 "`Image`".into()
1729 }
1730
1731 pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
1732 match self {
1733 ContentBlock::Empty => "",
1734 ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
1735 ContentBlock::EmbeddedResource { resource, markdown } => {
1736 if let Some(markdown) = markdown {
1737 markdown.read(cx).source()
1738 } else {
1739 Self::embedded_resource_label(resource)
1740 }
1741 }
1742 ContentBlock::ResourceLink { resource_link } => &resource_link.uri,
1743 ContentBlock::Image { .. } => "`Image`",
1744 }
1745 }
1746
1747 pub fn markdown(&self) -> Option<&Entity<Markdown>> {
1748 match self {
1749 ContentBlock::Empty => None,
1750 ContentBlock::Markdown { markdown } => Some(markdown),
1751 ContentBlock::EmbeddedResource { markdown, .. } => markdown.as_ref(),
1752 ContentBlock::ResourceLink { .. } => None,
1753 ContentBlock::Image { .. } => None,
1754 }
1755 }
1756
1757 pub fn resource_link(&self) -> Option<&acp::ResourceLink> {
1758 match self {
1759 ContentBlock::ResourceLink { resource_link } => Some(resource_link),
1760 _ => None,
1761 }
1762 }
1763
1764 pub fn image(&self) -> Option<(&Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
1765 match self {
1766 ContentBlock::Image { image, dimensions } => Some((image, *dimensions)),
1767 _ => None,
1768 }
1769 }
1770}
1771
1772enum TextResourceRenderMode {
1773 Markdown,
1774 CodeBlock(Option<&'static str>),
1775}
1776
1777fn text_resource_render_mode(mime_type: Option<&str>) -> TextResourceRenderMode {
1778 let Some(mime_type) = mime_type else {
1779 return TextResourceRenderMode::CodeBlock(None);
1780 };
1781 let Ok(mime) = mime_type.parse::<mime::Mime>() else {
1782 return TextResourceRenderMode::CodeBlock(None);
1783 };
1784
1785 let type_ = mime.type_().as_str();
1786 let subtype = mime.subtype().as_str();
1787 let suffix = mime.suffix().map(|suffix| suffix.as_str());
1788
1789 if matches!(
1790 (type_, subtype),
1791 ("text", "markdown") | ("text", "x-markdown")
1792 ) {
1793 return TextResourceRenderMode::Markdown;
1794 }
1795
1796 let language = match (type_, subtype, suffix) {
1797 (_, "json", _) | (_, _, Some("json")) => Some("json"),
1798 (_, "xml", _) | (_, _, Some("xml")) => Some("xml"),
1799 ("text", "html", _) => Some("html"),
1800 ("text", "css", _) => Some("css"),
1801 ("text", "csv", _) => Some("csv"),
1802 ("text", "tab-separated-values", _) => Some("tsv"),
1803 ("text", "javascript", _) | ("application", "javascript", _) => Some("javascript"),
1804 ("application", "x-javascript", _) => Some("javascript"),
1805 ("text", "typescript", _) | ("application", "typescript", _) => Some("typescript"),
1806 ("text", "x-shellscript", _) | ("application", "x-shellscript", _) => Some("sh"),
1807 ("application", "x-sh", _) => Some("sh"),
1808 ("text", "x-python", _) => Some("python"),
1809 ("text", "x-rust", _) => Some("rust"),
1810 ("text", "x-go", _) => Some("go"),
1811 ("text", "x-ruby", _) => Some("ruby"),
1812 ("text", "x-c", _) => Some("c"),
1813 // `mime` parses `text/x-c++` as subtype `x-c+` with an empty suffix.
1814 ("text", "x-c+", Some("")) => Some("cpp"),
1815 ("text", "plain", _) => None,
1816 ("text", _, _) => None,
1817 ("application", "graphql", _) => Some("graphql"),
1818 ("application", "toml", _) => Some("toml"),
1819 ("application", "yaml", _) | ("application", "x-yaml", _) => Some("yaml"),
1820 (_, _, Some("yaml" | "yml")) => Some("yaml"),
1821 _ => return TextResourceRenderMode::CodeBlock(None),
1822 };
1823
1824 TextResourceRenderMode::CodeBlock(language)
1825}
1826
1827#[derive(Debug)]
1828pub enum ToolCallContent {
1829 ContentBlock(ContentBlock),
1830 Diff(Entity<Diff>),
1831 Terminal(Entity<Terminal>),
1832}
1833
1834impl ToolCallContent {
1835 pub fn from_acp(
1836 content: acp::ToolCallContent,
1837 language_registry: Arc<LanguageRegistry>,
1838 path_style: PathStyle,
1839 terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
1840 cx: &mut App,
1841 ) -> Result<Option<Self>> {
1842 match content {
1843 acp::ToolCallContent::Content(acp::Content { content, .. }) => Ok(Some(
1844 Self::ContentBlock(ContentBlock::new_tool_call_content(
1845 content,
1846 &language_registry,
1847 path_style,
1848 cx,
1849 )),
1850 )),
1851 acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| {
1852 Diff::finalized(
1853 diff.path.to_string_lossy().into_owned(),
1854 diff.old_text,
1855 diff.new_text,
1856 language_registry,
1857 cx,
1858 )
1859 })))),
1860 acp::ToolCallContent::Terminal(acp::Terminal { terminal_id, .. }) => terminals
1861 .get(&terminal_id)
1862 .cloned()
1863 .map(|terminal| Some(Self::Terminal(terminal)))
1864 .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)),
1865 _ => Ok(None),
1866 }
1867 }
1868
1869 pub fn update_from_acp(
1870 &mut self,
1871 new: acp::ToolCallContent,
1872 language_registry: Arc<LanguageRegistry>,
1873 path_style: PathStyle,
1874 terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
1875 cx: &mut App,
1876 ) -> Result<bool> {
1877 // Update streaming text in place so the rendered markdown element is
1878 // reused across snapshots instead of being recreated (which flickers).
1879 if let (
1880 Self::ContentBlock(block),
1881 acp::ToolCallContent::Content(acp::Content { content, .. }),
1882 ) = (&mut *self, &new)
1883 && block.update_text_in_place(content, cx)
1884 {
1885 return Ok(true);
1886 }
1887
1888 let needs_update = match (&self, &new) {
1889 (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => {
1890 old_diff.read(cx).needs_update(
1891 new_diff.old_text.as_deref().unwrap_or(""),
1892 &new_diff.new_text,
1893 cx,
1894 )
1895 }
1896 _ => true,
1897 };
1898
1899 if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? {
1900 if needs_update {
1901 *self = update;
1902 }
1903 Ok(true)
1904 } else {
1905 Ok(false)
1906 }
1907 }
1908
1909 pub fn to_markdown(&self, cx: &App) -> String {
1910 match self {
1911 Self::ContentBlock(content) => content.to_markdown(cx).to_string(),
1912 Self::Diff(diff) => diff.read(cx).to_markdown(cx),
1913 Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx),
1914 }
1915 }
1916
1917 pub fn image(&self) -> Option<(&Arc<gpui::Image>, Option<gpui::Size<u32>>)> {
1918 match self {
1919 Self::ContentBlock(content) => content.image(),
1920 _ => None,
1921 }
1922 }
1923}
1924
1925#[derive(Debug, PartialEq)]
1926pub enum ToolCallUpdate {
1927 UpdateFields(acp::ToolCallUpdate),
1928 UpdateDiff(ToolCallUpdateDiff),
1929 UpdateTerminal(ToolCallUpdateTerminal),
1930}
1931
1932impl ToolCallUpdate {
1933 fn id(&self) -> &acp::ToolCallId {
1934 match self {
1935 Self::UpdateFields(update) => &update.tool_call_id,
1936 Self::UpdateDiff(diff) => &diff.id,
1937 Self::UpdateTerminal(terminal) => &terminal.id,
1938 }
1939 }
1940}
1941
1942impl From<acp::ToolCallUpdate> for ToolCallUpdate {
1943 fn from(update: acp::ToolCallUpdate) -> Self {
1944 Self::UpdateFields(update)
1945 }
1946}
1947
1948impl From<ToolCallUpdateDiff> for ToolCallUpdate {
1949 fn from(diff: ToolCallUpdateDiff) -> Self {
1950 Self::UpdateDiff(diff)
1951 }
1952}
1953
1954#[derive(Debug, PartialEq)]
1955pub struct ToolCallUpdateDiff {
1956 pub id: acp::ToolCallId,
1957 pub diff: Entity<Diff>,
1958}
1959
1960impl From<ToolCallUpdateTerminal> for ToolCallUpdate {
1961 fn from(terminal: ToolCallUpdateTerminal) -> Self {
1962 Self::UpdateTerminal(terminal)
1963 }
1964}
1965
1966#[derive(Debug, PartialEq)]
1967pub struct ToolCallUpdateTerminal {
1968 pub id: acp::ToolCallId,
1969 pub terminal: Entity<Terminal>,
1970}
1971
1972#[derive(Debug, Default)]
1973pub struct Plan {
1974 pub entries: Vec<PlanEntry>,
1975}
1976
1977#[derive(Debug)]
1978pub struct PlanStats<'a> {
1979 pub in_progress_entry: Option<&'a PlanEntry>,
1980 pub pending: u32,
1981 pub completed: u32,
1982}
1983
1984impl Plan {
1985 pub fn is_empty(&self) -> bool {
1986 self.entries.is_empty()
1987 }
1988
1989 pub fn stats(&self) -> PlanStats<'_> {
1990 let mut stats = PlanStats {
1991 in_progress_entry: None,
1992 pending: 0,
1993 completed: 0,
1994 };
1995
1996 for entry in &self.entries {
1997 match &entry.status {
1998 acp::PlanEntryStatus::Pending => {
1999 stats.pending += 1;
2000 }
2001 acp::PlanEntryStatus::InProgress => {
2002 stats.in_progress_entry = stats.in_progress_entry.or(Some(entry));
2003 stats.pending += 1;
2004 }
2005 acp::PlanEntryStatus::Completed => {
2006 stats.completed += 1;
2007 }
2008 _ => {}
2009 }
2010 }
2011
2012 stats
2013 }
2014}
2015
2016#[derive(Debug)]
2017pub struct PlanEntry {
2018 pub content: Entity<Markdown>,
2019 pub priority: acp::PlanEntryPriority,
2020 pub status: acp::PlanEntryStatus,
2021}
2022
2023impl PlanEntry {
2024 pub fn from_acp(entry: acp::PlanEntry, cx: &mut App) -> Self {
2025 Self {
2026 content: cx.new(|cx| Markdown::new(entry.content.into(), None, None, cx)),
2027 priority: entry.priority,
2028 status: entry.status,
2029 }
2030 }
2031}
2032
2033#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2034pub struct TokenUsage {
2035 pub max_tokens: u64,
2036 pub used_tokens: u64,
2037 pub input_tokens: u64,
2038 pub output_tokens: u64,
2039 pub max_output_tokens: Option<u64>,
2040}
2041
2042#[derive(Debug, Clone)]
2043pub struct SessionCost {
2044 pub amount: f64,
2045 pub currency: SharedString,
2046}
2047
2048pub const TOKEN_USAGE_WARNING_THRESHOLD: f32 = 0.8;
2049
2050impl TokenUsage {
2051 pub fn ratio(&self) -> TokenUsageRatio {
2052 #[cfg(debug_assertions)]
2053 let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD")
2054 .unwrap_or(TOKEN_USAGE_WARNING_THRESHOLD.to_string())
2055 .parse()
2056 .unwrap();
2057 #[cfg(not(debug_assertions))]
2058 let warning_threshold: f32 = TOKEN_USAGE_WARNING_THRESHOLD;
2059
2060 // When the maximum is unknown because there is no selected model,
2061 // avoid showing the token limit warning.
2062 if self.max_tokens == 0 {
2063 TokenUsageRatio::Normal
2064 } else if self.used_tokens >= self.max_tokens {
2065 TokenUsageRatio::Exceeded
2066 } else if self.used_tokens as f32 / self.max_tokens as f32 >= warning_threshold {
2067 TokenUsageRatio::Warning
2068 } else {
2069 TokenUsageRatio::Normal
2070 }
2071 }
2072}
2073
2074#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2075pub enum TokenUsageRatio {
2076 Normal,
2077 Warning,
2078 Exceeded,
2079}
2080
2081#[derive(Debug, Clone)]
2082pub struct RetryStatus {
2083 pub last_error: SharedString,
2084 pub attempt: usize,
2085 pub max_attempts: usize,
2086 pub started_at: Instant,
2087 pub duration: Duration,
2088 pub meta: Option<acp::Meta>,
2089}
2090
2091pub const REFUSAL_FALLBACK_MODEL_META_KEY: &str = "refusal_fallback_model";
2092
2093pub fn meta_with_refusal_fallback(model_name: &str) -> acp::Meta {
2094 acp::Meta::from_iter([(REFUSAL_FALLBACK_MODEL_META_KEY.into(), model_name.into())])
2095}
2096
2097pub fn refusal_fallback_model_from_meta(meta: &Option<acp::Meta>) -> Option<SharedString> {
2098 meta.as_ref()
2099 .and_then(|m| m.get(REFUSAL_FALLBACK_MODEL_META_KEY))
2100 .and_then(|v| v.as_str())
2101 .map(|s| SharedString::from(s.to_owned()))
2102}
2103
2104struct RunningTurn {
2105 id: u32,
2106 send_task: Task<()>,
2107}
2108
2109pub struct AcpThread {
2110 session_id: acp::SessionId,
2111 work_dirs: Option<PathList>,
2112 parent_session_id: Option<acp::SessionId>,
2113 title: Option<SharedString>,
2114 provisional_title: Option<SharedString>,
2115 entries: Vec<AgentThreadEntry>,
2116 elicitations: ElicitationStore,
2117 plan: Plan,
2118 project: Entity<Project>,
2119 action_log: Entity<ActionLog>,
2120 _git_store_subscription: Subscription,
2121 update_last_checkpoint_if_changed_task: Option<Task<Result<()>>>,
2122 shared_buffers: HashMap<Entity<Buffer>, BufferSnapshot>,
2123 turn_id: u32,
2124 running_turn: Option<RunningTurn>,
2125 connection: Rc<dyn AgentConnection>,
2126 token_usage: Option<TokenUsage>,
2127 cost: Option<SessionCost>,
2128 prompt_capabilities: acp::PromptCapabilities,
2129 available_commands: Vec<acp::AvailableCommand>,
2130 _observe_prompt_capabilities: Task<anyhow::Result<()>>,
2131 terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
2132 pending_terminal_output: HashMap<acp::TerminalId, Vec<Vec<u8>>>,
2133 pending_terminal_exit: HashMap<acp::TerminalId, acp::TerminalExitStatus>,
2134 had_error: bool,
2135 /// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
2136 draft_prompt: Option<Vec<acp::ContentBlock>>,
2137 /// The initial scroll position for the thread view, set during session registration.
2138 ui_scroll_position: Option<gpui::ListOffset>,
2139 /// Buffer for smooth text streaming. Holds text that has been received from
2140 /// the model but not yet revealed in the UI. A timer task drains this buffer
2141 /// gradually to create a fluid typing effect instead of choppy chunk-at-a-time
2142 /// updates.
2143 streaming_text_buffer: Option<StreamingTextBuffer>,
2144}
2145
2146struct StreamingTextBuffer {
2147 /// Text received from the model but not yet appended to the Markdown source.
2148 pending: String,
2149 /// The number of bytes to reveal per timer turn.
2150 bytes_to_reveal_per_tick: usize,
2151 /// The Markdown entity being streamed into.
2152 target: Entity<Markdown>,
2153 /// Timer task that periodically moves text from `pending` into `source`.
2154 _reveal_task: Task<()>,
2155}
2156
2157impl StreamingTextBuffer {
2158 /// The number of milliseconds between each timer tick, controlling how quickly
2159 /// text is revealed.
2160 const TASK_UPDATE_MS: u64 = 16;
2161 /// The time in milliseconds to reveal the entire pending text.
2162 const REVEAL_TARGET: f32 = 200.0;
2163}
2164
2165impl From<&AcpThread> for ActionLogTelemetry {
2166 fn from(value: &AcpThread) -> Self {
2167 Self {
2168 agent_telemetry_id: value.connection().telemetry_id(),
2169 session_id: value.session_id.0.clone(),
2170 }
2171 }
2172}
2173
2174#[derive(Debug)]
2175pub enum AcpThreadEvent {
2176 StatusChanged,
2177 PromptUpdated,
2178 NewEntry,
2179 TitleUpdated,
2180 TokenUsageUpdated,
2181 EntryUpdated(usize),
2182 EntriesRemoved(Range<usize>),
2183 ToolAuthorizationRequested(acp::ToolCallId),
2184 ToolAuthorizationReceived(acp::ToolCallId),
2185 ElicitationRequested(ElicitationEntryId),
2186 ElicitationResponded(ElicitationEntryId),
2187 Retry(RetryStatus),
2188 SubagentSpawned(acp::SessionId),
2189 Stopped(acp::StopReason),
2190 Error,
2191 LoadError(LoadError),
2192 PromptCapabilitiesUpdated,
2193 Refusal,
2194 AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
2195 ModeUpdated(acp::SessionModeId),
2196 ConfigOptionsUpdated(Vec<acp::SessionConfigOption>),
2197 WorkingDirectoriesUpdated,
2198}
2199
2200impl EventEmitter<AcpThreadEvent> for AcpThread {}
2201
2202#[derive(Debug, Clone)]
2203pub enum TerminalProviderEvent {
2204 Created {
2205 terminal_id: acp::TerminalId,
2206 label: String,
2207 cwd: Option<PathBuf>,
2208 output_byte_limit: Option<u64>,
2209 terminal: Entity<::terminal::Terminal>,
2210 },
2211 Output {
2212 terminal_id: acp::TerminalId,
2213 data: Vec<u8>,
2214 },
2215 TitleChanged {
2216 terminal_id: acp::TerminalId,
2217 title: String,
2218 },
2219 Exit {
2220 terminal_id: acp::TerminalId,
2221 status: acp::TerminalExitStatus,
2222 },
2223}
2224
2225#[derive(Debug, Clone)]
2226pub enum TerminalProviderCommand {
2227 WriteInput {
2228 terminal_id: acp::TerminalId,
2229 bytes: Vec<u8>,
2230 },
2231 Resize {
2232 terminal_id: acp::TerminalId,
2233 cols: u16,
2234 rows: u16,
2235 },
2236 Close {
2237 terminal_id: acp::TerminalId,
2238 },
2239}
2240
2241#[derive(PartialEq, Eq, Debug)]
2242pub enum ThreadStatus {
2243 Idle,
2244 Generating,
2245}
2246
2247#[derive(Debug, Clone)]
2248pub enum LoadError {
2249 Unsupported {
2250 command: SharedString,
2251 current_version: SharedString,
2252 minimum_version: SharedString,
2253 },
2254 FailedToInstall(SharedString),
2255 Exited {
2256 status: ExitStatus,
2257 stderr: Option<SharedString>,
2258 },
2259 /// The agent no longer holds the session this thread names.
2260 ///
2261 /// Omega's thread record outlives the external agent's session store, so
2262 /// reopening an old thread after the agent has forgotten it is ordinary
2263 /// rather than exceptional. It surfaced as "Failed to Launch: Resource not
2264 /// found: <uuid>", which names a thing the reader cannot act on and reads
2265 /// like the agent failed to start. Nothing failed to launch: the
2266 /// conversation is gone and a new one is the way forward.
2267 SessionGone,
2268 Other(SharedString),
2269}
2270
2271impl Display for LoadError {
2272 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
2273 match self {
2274 LoadError::Unsupported {
2275 command: path,
2276 current_version,
2277 minimum_version,
2278 } => {
2279 write!(
2280 f,
2281 "version {current_version} from {path} is not supported (need at least {minimum_version})"
2282 )
2283 }
2284 LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"),
2285 LoadError::Exited { status, .. } => write!(f, "Server exited with status {status}"),
2286 LoadError::SessionGone => write!(
2287 f,
2288 "the agent no longer has this conversation; start a new thread to keep going"
2289 ),
2290 LoadError::Other(msg) => write!(f, "{msg}"),
2291 }
2292 }
2293}
2294
2295impl Error for LoadError {}
2296
2297impl AcpThread {
2298 pub fn new(
2299 parent_session_id: Option<acp::SessionId>,
2300 title: Option<SharedString>,
2301 work_dirs: Option<PathList>,
2302 connection: Rc<dyn AgentConnection>,
2303 project: Entity<Project>,
2304 action_log: Entity<ActionLog>,
2305 session_id: acp::SessionId,
2306 mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
2307 cx: &mut Context<Self>,
2308 ) -> Self {
2309 let prompt_capabilities = prompt_capabilities_rx.borrow().clone();
2310 let task = cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| {
2311 loop {
2312 let caps = prompt_capabilities_rx.recv().await?;
2313 this.update(cx, |this, cx| {
2314 this.prompt_capabilities = caps;
2315 cx.emit(AcpThreadEvent::PromptCapabilitiesUpdated);
2316 })?;
2317 }
2318 });
2319
2320 let git_store = project.read(cx).git_store().clone();
2321 let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| {
2322 if matches!(
2323 event,
2324 GitStoreEvent::RepositoryUpdated(
2325 _,
2326 RepositoryEvent::StatusesChanged | RepositoryEvent::HeadChanged,
2327 _
2328 )
2329 ) {
2330 this.update_last_checkpoint_if_changed_task =
2331 Some(this.update_last_checkpoint_if_changed(cx));
2332 }
2333 });
2334
2335 Self {
2336 parent_session_id,
2337 work_dirs,
2338 action_log,
2339 _git_store_subscription,
2340 update_last_checkpoint_if_changed_task: None,
2341 shared_buffers: Default::default(),
2342 entries: Default::default(),
2343 elicitations: ElicitationStore::default(),
2344 plan: Default::default(),
2345 title,
2346 provisional_title: None,
2347 project,
2348 running_turn: None,
2349 turn_id: 0,
2350 connection,
2351 session_id,
2352 token_usage: None,
2353 cost: None,
2354 prompt_capabilities,
2355 available_commands: Vec::new(),
2356 _observe_prompt_capabilities: task,
2357 terminals: HashMap::default(),
2358 pending_terminal_output: HashMap::default(),
2359 pending_terminal_exit: HashMap::default(),
2360 had_error: false,
2361 draft_prompt: None,
2362 ui_scroll_position: None,
2363 streaming_text_buffer: None,
2364 }
2365 }
2366
2367 pub fn parent_session_id(&self) -> Option<&acp::SessionId> {
2368 self.parent_session_id.as_ref()
2369 }
2370
2371 pub fn prompt_capabilities(&self) -> acp::PromptCapabilities {
2372 self.prompt_capabilities.clone()
2373 }
2374
2375 pub fn available_commands(&self) -> &[acp::AvailableCommand] {
2376 &self.available_commands
2377 }
2378
2379 pub fn is_draft_thread(&self) -> bool {
2380 self.entries().is_empty()
2381 }
2382
2383 pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
2384 self.draft_prompt.as_deref()
2385 }
2386
2387 pub fn set_draft_prompt(
2388 &mut self,
2389 prompt: Option<Vec<acp::ContentBlock>>,
2390 cx: &mut Context<Self>,
2391 ) {
2392 cx.emit(AcpThreadEvent::PromptUpdated);
2393 self.draft_prompt = prompt;
2394 }
2395
2396 pub fn ui_scroll_position(&self) -> Option<gpui::ListOffset> {
2397 self.ui_scroll_position
2398 }
2399
2400 pub fn set_ui_scroll_position(&mut self, position: Option<gpui::ListOffset>) {
2401 self.ui_scroll_position = position;
2402 }
2403
2404 pub fn connection(&self) -> &Rc<dyn AgentConnection> {
2405 &self.connection
2406 }
2407
2408 /// Replace the connection facade while keeping the established ACP
2409 /// session.
2410 ///
2411 /// An adapter can delegate session creation to an inner ACP connection and
2412 /// then install its policy-enforcing facade here. All later prompts,
2413 /// cancellation, and executor disclosure must go through that facade.
2414 pub fn replace_connection(&mut self, connection: Rc<dyn AgentConnection>) {
2415 self.connection = connection;
2416 }
2417
2418 pub fn action_log(&self) -> &Entity<ActionLog> {
2419 &self.action_log
2420 }
2421
2422 pub fn project(&self) -> &Entity<Project> {
2423 &self.project
2424 }
2425
2426 pub fn title(&self) -> Option<SharedString> {
2427 self.title
2428 .clone()
2429 .or_else(|| self.provisional_title.clone())
2430 }
2431
2432 pub fn has_provisional_title(&self) -> bool {
2433 self.provisional_title.is_some()
2434 }
2435
2436 pub fn entries(&self) -> &[AgentThreadEntry] {
2437 &self.entries
2438 }
2439
2440 pub fn is_compacting(&self) -> bool {
2441 self.entries.last().is_some_and(|entry| {
2442 matches!(
2443 entry,
2444 AgentThreadEntry::ContextCompaction(compaction) if compaction.is_in_progress()
2445 )
2446 })
2447 }
2448
2449 pub fn invalidate_mermaid_caches(&self, cx: &mut App) {
2450 for entry in &self.entries {
2451 let chunks = match entry {
2452 AgentThreadEntry::AssistantMessage(message) => &message.chunks,
2453 _ => continue,
2454 };
2455 for chunk in chunks {
2456 let block = match chunk {
2457 AssistantMessageChunk::Message { block, .. } => block,
2458 AssistantMessageChunk::Thought { block, .. } => block,
2459 };
2460 if let Some(markdown) = block.markdown() {
2461 markdown.update(cx, |markdown, cx| {
2462 markdown.invalidate_mermaid_cache(cx);
2463 });
2464 }
2465 }
2466 }
2467 }
2468
2469 pub fn session_id(&self) -> &acp::SessionId {
2470 &self.session_id
2471 }
2472
2473 pub fn supports_truncate(&self, cx: &App) -> bool {
2474 self.connection.truncate(&self.session_id, cx).is_some()
2475 }
2476
2477 pub fn work_dirs(&self) -> Option<&PathList> {
2478 self.work_dirs.as_ref()
2479 }
2480
2481 pub fn set_work_dirs(&mut self, work_dirs: PathList, cx: &mut Context<Self>) {
2482 self.work_dirs = Some(work_dirs);
2483 cx.emit(AcpThreadEvent::WorkingDirectoriesUpdated)
2484 }
2485
2486 pub fn status(&self) -> ThreadStatus {
2487 if self.running_turn.is_some() {
2488 ThreadStatus::Generating
2489 } else {
2490 ThreadStatus::Idle
2491 }
2492 }
2493
2494 pub fn had_error(&self) -> bool {
2495 self.had_error
2496 }
2497
2498 pub fn is_waiting_for_confirmation(&self) -> bool {
2499 for entry in self.entries.iter().rev() {
2500 match entry {
2501 AgentThreadEntry::UserMessage(_) => return false,
2502 AgentThreadEntry::ToolCall(ToolCall {
2503 status: ToolCallStatus::WaitingForConfirmation { .. },
2504 ..
2505 }) => return true,
2506 AgentThreadEntry::Elicitation(elicitation_id)
2507 if self.elicitations.elicitation(elicitation_id).is_some_and(
2508 |(_, elicitation)| {
2509 matches!(elicitation.status, ElicitationStatus::Pending { .. })
2510 },
2511 ) =>
2512 {
2513 return true;
2514 }
2515 AgentThreadEntry::ToolCall(_)
2516 | AgentThreadEntry::Elicitation(_)
2517 | AgentThreadEntry::AssistantMessage(_)
2518 | AgentThreadEntry::CompletedPlan(_)
2519 | AgentThreadEntry::ContextCompaction(_)
2520 | AgentThreadEntry::SystemNote(_) => {}
2521 }
2522 }
2523 false
2524 }
2525
2526 pub fn token_usage(&self) -> Option<&TokenUsage> {
2527 self.token_usage.as_ref()
2528 }
2529
2530 pub fn cost(&self) -> Option<&SessionCost> {
2531 self.cost.as_ref()
2532 }
2533
2534 pub fn has_pending_edit_tool_calls(&self) -> bool {
2535 for entry in self.entries.iter().rev() {
2536 match entry {
2537 AgentThreadEntry::UserMessage(_) => return false,
2538 AgentThreadEntry::ToolCall(
2539 call @ ToolCall {
2540 status: ToolCallStatus::InProgress | ToolCallStatus::Pending,
2541 ..
2542 },
2543 ) if call.diffs().next().is_some() => {
2544 return true;
2545 }
2546 AgentThreadEntry::ToolCall(_)
2547 | AgentThreadEntry::Elicitation(_)
2548 | AgentThreadEntry::AssistantMessage(_)
2549 | AgentThreadEntry::CompletedPlan(_)
2550 | AgentThreadEntry::ContextCompaction(_)
2551 | AgentThreadEntry::SystemNote(_) => {}
2552 }
2553 }
2554
2555 false
2556 }
2557
2558 pub fn has_in_progress_tool_calls(&self) -> bool {
2559 for entry in self.entries.iter().rev() {
2560 match entry {
2561 AgentThreadEntry::UserMessage(_) => return false,
2562 AgentThreadEntry::ToolCall(ToolCall {
2563 status: ToolCallStatus::InProgress | ToolCallStatus::Pending,
2564 ..
2565 }) => {
2566 return true;
2567 }
2568 AgentThreadEntry::ToolCall(_)
2569 | AgentThreadEntry::Elicitation(_)
2570 | AgentThreadEntry::AssistantMessage(_)
2571 | AgentThreadEntry::CompletedPlan(_)
2572 | AgentThreadEntry::ContextCompaction(_)
2573 | AgentThreadEntry::SystemNote(_) => {}
2574 }
2575 }
2576
2577 false
2578 }
2579
2580 pub fn used_tools_since_last_user_message(&self) -> bool {
2581 for entry in self.entries.iter().rev() {
2582 match entry {
2583 AgentThreadEntry::UserMessage(..) => return false,
2584 AgentThreadEntry::AssistantMessage(..)
2585 | AgentThreadEntry::CompletedPlan(..)
2586 | AgentThreadEntry::ContextCompaction(_)
2587 | AgentThreadEntry::SystemNote(_)
2588 | AgentThreadEntry::Elicitation(_) => continue,
2589 AgentThreadEntry::ToolCall(..) => return true,
2590 }
2591 }
2592
2593 false
2594 }
2595
2596 pub fn handle_session_update(
2597 &mut self,
2598 update: acp::SessionUpdate,
2599 cx: &mut Context<Self>,
2600 ) -> Result<(), acp::Error> {
2601 match update {
2602 acp::SessionUpdate::UserMessageChunk(acp::ContentChunk {
2603 content,
2604 message_id,
2605 ..
2606 }) => {
2607 // We optimistically add the full user prompt before calling `prompt`.
2608 // Some ACP servers echo user chunks back over updates. Skip echoed
2609 // chunks only when they match the local optimistic message.
2610 let already_in_user_message = self
2611 .entries
2612 .last_mut()
2613 .and_then(|entry| match entry {
2614 AgentThreadEntry::UserMessage(message) => Some(message),
2615 _ => None,
2616 })
2617 .is_some_and(|message| {
2618 let already_in_user_message = message.is_optimistic
2619 && message.chunks.contains(&content)
2620 && can_merge_message_chunks(
2621 message.protocol_id.as_ref(),
2622 message_id.as_ref(),
2623 );
2624 if already_in_user_message && message.protocol_id.is_none() {
2625 message.protocol_id = message_id.clone();
2626 }
2627 already_in_user_message
2628 });
2629 if !already_in_user_message {
2630 self.push_user_content_block_from_agent(message_id, content, cx);
2631 }
2632 }
2633 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk {
2634 content,
2635 message_id,
2636 ..
2637 }) => {
2638 self.push_assistant_content_block_with_message_id(
2639 message_id, content, false, false, cx,
2640 );
2641 }
2642 acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk {
2643 content,
2644 message_id,
2645 ..
2646 }) => {
2647 self.push_assistant_content_block_with_message_id(
2648 message_id, content, true, false, cx,
2649 );
2650 }
2651 acp::SessionUpdate::ToolCall(tool_call) => {
2652 self.upsert_tool_call(tool_call, cx)?;
2653 }
2654 acp::SessionUpdate::ToolCallUpdate(tool_call_update) => {
2655 self.update_tool_call(tool_call_update, cx)?;
2656 }
2657 acp::SessionUpdate::Plan(plan) => {
2658 self.update_plan(plan, cx);
2659 }
2660 acp::SessionUpdate::SessionInfoUpdate(info_update) => {
2661 if let MaybeUndefined::Value(title) = info_update.title {
2662 let had_provisional = self.provisional_title.take().is_some();
2663 let title: SharedString = title.into();
2664 if self.title.as_ref() != Some(&title) {
2665 self.title = Some(title);
2666 cx.emit(AcpThreadEvent::TitleUpdated);
2667 } else if had_provisional {
2668 cx.emit(AcpThreadEvent::TitleUpdated);
2669 }
2670 }
2671 }
2672 acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate {
2673 available_commands,
2674 ..
2675 }) => {
2676 self.available_commands = available_commands.clone();
2677 cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands));
2678 }
2679 acp::SessionUpdate::CurrentModeUpdate(acp::CurrentModeUpdate {
2680 current_mode_id,
2681 ..
2682 }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)),
2683 acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate {
2684 config_options,
2685 ..
2686 }) => cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options)),
2687 acp::SessionUpdate::UsageUpdate(update) => {
2688 let usage = self.token_usage.get_or_insert_with(Default::default);
2689 usage.max_tokens = update.size;
2690 usage.used_tokens = update.used;
2691 if let Some(cost) = update.cost {
2692 self.cost = Some(SessionCost {
2693 amount: cost.amount,
2694 currency: cost.currency.into(),
2695 });
2696 }
2697 cx.emit(AcpThreadEvent::TokenUsageUpdated);
2698 }
2699 _ => {}
2700 }
2701 Ok(())
2702 }
2703
2704 pub fn push_user_content_block(
2705 &mut self,
2706 client_id: Option<ClientUserMessageId>,
2707 chunk: acp::ContentBlock,
2708 cx: &mut Context<Self>,
2709 ) {
2710 self.push_user_content_block_with_indent(client_id, chunk, false, cx)
2711 }
2712
2713 pub fn push_user_content_block_with_indent(
2714 &mut self,
2715 client_id: Option<ClientUserMessageId>,
2716 chunk: acp::ContentBlock,
2717 indented: bool,
2718 cx: &mut Context<Self>,
2719 ) {
2720 self.push_user_content_block_with_protocol_id(
2721 client_id.clone(),
2722 client_id.is_some(),
2723 None,
2724 chunk,
2725 indented,
2726 cx,
2727 )
2728 }
2729
2730 fn push_user_content_block_from_agent(
2731 &mut self,
2732 id: Option<acp::MessageId>,
2733 chunk: acp::ContentBlock,
2734 cx: &mut Context<Self>,
2735 ) {
2736 self.push_user_content_block_with_protocol_id(None, false, id, chunk, false, cx)
2737 }
2738
2739 fn push_user_content_block_with_protocol_id(
2740 &mut self,
2741 incoming_client_id: Option<ClientUserMessageId>,
2742 is_optimistic: bool,
2743 protocol_id: Option<acp::MessageId>,
2744 chunk: acp::ContentBlock,
2745 indented: bool,
2746 cx: &mut Context<Self>,
2747 ) {
2748 let language_registry = self.project.read(cx).languages().clone();
2749 let path_style = self.project.read(cx).path_style(cx);
2750 let entries_len = self.entries.len();
2751
2752 if let Some(last_entry) = self.entries.last_mut()
2753 && let AgentThreadEntry::UserMessage(UserMessage {
2754 protocol_id: existing_protocol_id,
2755 client_id: existing_client_id,
2756 content,
2757 chunks,
2758 is_optimistic: existing_is_optimistic,
2759 indented: existing_indented,
2760 ..
2761 }) = last_entry
2762 && *existing_indented == indented
2763 && can_merge_message_chunks(existing_protocol_id.as_ref(), protocol_id.as_ref())
2764 && !(*existing_is_optimistic
2765 && !is_optimistic
2766 && existing_protocol_id.is_none()
2767 && protocol_id.is_some())
2768 {
2769 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
2770 if let Some(incoming_client_id) = incoming_client_id {
2771 *existing_client_id = Some(incoming_client_id);
2772 }
2773 *existing_is_optimistic |= is_optimistic;
2774 if existing_protocol_id.is_none() {
2775 *existing_protocol_id = protocol_id;
2776 }
2777 content.append(chunk.clone(), &language_registry, path_style, cx);
2778 chunks.push(chunk);
2779 let idx = entries_len - 1;
2780 cx.emit(AcpThreadEvent::EntryUpdated(idx));
2781 } else {
2782 let content = ContentBlock::new(chunk.clone(), &language_registry, path_style, cx);
2783 self.push_entry(
2784 AgentThreadEntry::UserMessage(UserMessage {
2785 protocol_id,
2786 client_id: incoming_client_id,
2787 is_optimistic,
2788 content,
2789 chunks: vec![chunk],
2790 checkpoint: None,
2791 indented,
2792 }),
2793 cx,
2794 );
2795 }
2796 }
2797
2798 pub fn push_assistant_content_block(
2799 &mut self,
2800 chunk: acp::ContentBlock,
2801 is_thought: bool,
2802 cx: &mut Context<Self>,
2803 ) {
2804 self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
2805 }
2806
2807 pub fn push_assistant_content_block_with_indent(
2808 &mut self,
2809 chunk: acp::ContentBlock,
2810 is_thought: bool,
2811 indented: bool,
2812 cx: &mut Context<Self>,
2813 ) {
2814 self.push_assistant_content_block_with_message_id(None, chunk, is_thought, indented, cx)
2815 }
2816
2817 fn push_assistant_content_block_with_message_id(
2818 &mut self,
2819 message_id: Option<acp::MessageId>,
2820 chunk: acp::ContentBlock,
2821 is_thought: bool,
2822 indented: bool,
2823 cx: &mut Context<Self>,
2824 ) {
2825 let path_style = self.project.read(cx).path_style(cx);
2826
2827 // For text chunks going to an existing Markdown block, buffer for smooth
2828 // streaming instead of appending all at once which may feel more choppy.
2829 if let acp::ContentBlock::Text(text_content) = &chunk {
2830 if let Some(markdown) =
2831 self.streaming_markdown_target(message_id.as_ref(), is_thought, indented)
2832 {
2833 let entries_len = self.entries.len();
2834 cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
2835 self.buffer_streaming_text(&markdown, text_content.text.clone(), cx);
2836 return;
2837 }
2838 }
2839
2840 let language_registry = self.project.read(cx).languages().clone();
2841 let entries_len = self.entries.len();
2842 if let Some(last_entry) = self.entries.last_mut()
2843 && let AgentThreadEntry::AssistantMessage(AssistantMessage {
2844 chunks,
2845 indented: existing_indented,
2846 is_subagent_output: _,
2847 }) = last_entry
2848 && *existing_indented == indented
2849 {
2850 let idx = entries_len - 1;
2851 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
2852 cx.emit(AcpThreadEvent::EntryUpdated(idx));
2853 match (chunks.last_mut(), is_thought) {
2854 (
2855 Some(AssistantMessageChunk::Message {
2856 id: existing_id,
2857 block,
2858 }),
2859 false,
2860 )
2861 | (
2862 Some(AssistantMessageChunk::Thought {
2863 id: existing_id,
2864 block,
2865 }),
2866 true,
2867 ) if can_merge_message_chunks(existing_id.as_ref(), message_id.as_ref()) => {
2868 if existing_id.is_none() {
2869 *existing_id = message_id;
2870 }
2871 block.append(chunk, &language_registry, path_style, cx)
2872 }
2873 _ => {
2874 let block = ContentBlock::new(chunk, &language_registry, path_style, cx);
2875 if is_thought {
2876 chunks.push(AssistantMessageChunk::Thought {
2877 id: message_id,
2878 block,
2879 })
2880 } else {
2881 chunks.push(AssistantMessageChunk::Message {
2882 id: message_id,
2883 block,
2884 })
2885 }
2886 }
2887 }
2888 } else {
2889 let block = ContentBlock::new(chunk, &language_registry, path_style, cx);
2890 let chunk = if is_thought {
2891 AssistantMessageChunk::Thought {
2892 id: message_id,
2893 block,
2894 }
2895 } else {
2896 AssistantMessageChunk::Message {
2897 id: message_id,
2898 block,
2899 }
2900 };
2901
2902 self.push_entry(
2903 AgentThreadEntry::AssistantMessage(AssistantMessage {
2904 chunks: vec![chunk],
2905 indented,
2906 is_subagent_output: false,
2907 }),
2908 cx,
2909 );
2910 }
2911 }
2912
2913 fn streaming_markdown_target(
2914 &mut self,
2915 message_id: Option<&acp::MessageId>,
2916 is_thought: bool,
2917 indented: bool,
2918 ) -> Option<Entity<Markdown>> {
2919 let last_entry = self.entries.last_mut()?;
2920 if let AgentThreadEntry::AssistantMessage(AssistantMessage {
2921 chunks,
2922 indented: existing_indented,
2923 ..
2924 }) = last_entry
2925 && *existing_indented == indented
2926 && let [.., chunk] = chunks.as_mut_slice()
2927 {
2928 match (chunk, is_thought) {
2929 (
2930 AssistantMessageChunk::Message {
2931 id: existing_id,
2932 block: ContentBlock::Markdown { markdown },
2933 },
2934 false,
2935 )
2936 | (
2937 AssistantMessageChunk::Thought {
2938 id: existing_id,
2939 block: ContentBlock::Markdown { markdown },
2940 },
2941 true,
2942 ) if can_merge_message_chunks(existing_id.as_ref(), message_id) => {
2943 if existing_id.is_none() {
2944 *existing_id = message_id.cloned();
2945 }
2946 Some(markdown.clone())
2947 }
2948 _ => None,
2949 }
2950 } else {
2951 None
2952 }
2953 }
2954
2955 /// Add text to the streaming buffer. If the target changed (e.g. switching
2956 /// from thoughts to message text), flush the old buffer first.
2957 fn buffer_streaming_text(
2958 &mut self,
2959 markdown: &Entity<Markdown>,
2960 text: String,
2961 cx: &mut Context<Self>,
2962 ) {
2963 if let Some(buffer) = &mut self.streaming_text_buffer {
2964 if buffer.target.entity_id() == markdown.entity_id() {
2965 buffer.pending.push_str(&text);
2966
2967 buffer.bytes_to_reveal_per_tick = (buffer.pending.len() as f32
2968 / StreamingTextBuffer::REVEAL_TARGET
2969 * StreamingTextBuffer::TASK_UPDATE_MS as f32)
2970 .ceil() as usize;
2971 return;
2972 }
2973 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
2974 }
2975
2976 let target = markdown.clone();
2977 let _reveal_task = self.start_streaming_reveal(cx);
2978 let pending_len = text.len();
2979 let bytes_to_reveal = (pending_len as f32 / StreamingTextBuffer::REVEAL_TARGET
2980 * StreamingTextBuffer::TASK_UPDATE_MS as f32)
2981 .ceil() as usize;
2982 self.streaming_text_buffer = Some(StreamingTextBuffer {
2983 pending: text,
2984 bytes_to_reveal_per_tick: bytes_to_reveal,
2985 target,
2986 _reveal_task,
2987 });
2988 }
2989
2990 /// Flush all buffered streaming text into the Markdown entity immediately.
2991 fn flush_streaming_text(
2992 streaming_text_buffer: &mut Option<StreamingTextBuffer>,
2993 cx: &mut Context<Self>,
2994 ) {
2995 if let Some(buffer) = streaming_text_buffer.take() {
2996 if !buffer.pending.is_empty() {
2997 buffer
2998 .target
2999 .update(cx, |markdown, cx| markdown.append(&buffer.pending, cx));
3000 }
3001 }
3002 }
3003
3004 /// Spawns a foreground task that periodically drains
3005 /// `streaming_text_buffer.pending` into the target `Markdown` entity,
3006 /// producing smooth, continuous text output.
3007 fn start_streaming_reveal(&self, cx: &mut Context<Self>) -> Task<()> {
3008 cx.spawn(async move |this, cx| {
3009 loop {
3010 cx.background_executor()
3011 .timer(Duration::from_millis(StreamingTextBuffer::TASK_UPDATE_MS))
3012 .await;
3013
3014 let should_continue = this
3015 .update(cx, |this, cx| {
3016 let Some(buffer) = &mut this.streaming_text_buffer else {
3017 return false;
3018 };
3019
3020 if buffer.pending.is_empty() {
3021 return true;
3022 }
3023
3024 let pending_len = buffer.pending.len();
3025
3026 let byte_boundary = buffer
3027 .pending
3028 .ceil_char_boundary(buffer.bytes_to_reveal_per_tick)
3029 .min(pending_len);
3030
3031 buffer.target.update(cx, |markdown: &mut Markdown, cx| {
3032 markdown.append(&buffer.pending[..byte_boundary], cx);
3033 buffer.pending.drain(..byte_boundary);
3034 });
3035
3036 true
3037 })
3038 .unwrap_or(false);
3039
3040 if !should_continue {
3041 break;
3042 }
3043 }
3044 })
3045 }
3046
3047 fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context<Self>) {
3048 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
3049 self.entries.push(entry);
3050 cx.emit(AcpThreadEvent::NewEntry);
3051 }
3052
3053 /// OMEGA-DELTA-0045. Append a host-authored note to the thread the owner
3054 /// reads. Returns `false` when a note with this id is already present.
3055 ///
3056 /// Idempotent on the id rather than last-write-wins: the note is a record
3057 /// that something happened, so a retry must not be able to rewrite what
3058 /// the owner was already shown, and must not double it either.
3059 pub fn push_system_note(&mut self, note: SystemNote, cx: &mut Context<Self>) -> bool {
3060 if self
3061 .entries
3062 .iter()
3063 .any(|entry| matches!(entry, AgentThreadEntry::SystemNote(existing) if existing.id == note.id))
3064 {
3065 return false;
3066 }
3067 self.push_entry(AgentThreadEntry::SystemNote(note), cx);
3068 true
3069 }
3070
3071 pub fn push_context_compaction(
3072 &mut self,
3073 compaction: ContextCompaction,
3074 cx: &mut Context<Self>,
3075 ) {
3076 if let Some(ix) =
3077 self.entries
3078 .iter()
3079 .enumerate()
3080 .rev()
3081 .find_map(|(ix, entry)| match entry {
3082 AgentThreadEntry::ContextCompaction(c) if &c.id == &compaction.id => Some(ix),
3083 _ => None,
3084 })
3085 {
3086 self.entries[ix] = AgentThreadEntry::ContextCompaction(compaction);
3087 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3088 } else {
3089 self.push_entry(AgentThreadEntry::ContextCompaction(compaction), cx);
3090 }
3091 }
3092
3093 pub fn update_context_compaction(
3094 &mut self,
3095 update: ContextCompactionUpdate,
3096 cx: &mut Context<Self>,
3097 ) {
3098 let language_registry = self.project.read(cx).languages().clone();
3099 let Some((ix, compaction)) =
3100 self.entries
3101 .iter_mut()
3102 .enumerate()
3103 .rev()
3104 .find_map(|(ix, entry)| match entry {
3105 AgentThreadEntry::ContextCompaction(c) if &c.id == &update.id => Some((ix, c)),
3106 _ => None,
3107 })
3108 else {
3109 return;
3110 };
3111
3112 if !update.summary_delta.is_empty() {
3113 if compaction.summary.is_none() {
3114 compaction.summary = Some(cx.new(|cx| {
3115 Markdown::new(
3116 update.summary_delta.into(),
3117 Some(language_registry),
3118 None,
3119 cx,
3120 )
3121 }));
3122 } else if let Some(summary) = compaction.summary.clone() {
3123 summary.update(cx, |markdown, cx| {
3124 markdown.append(&update.summary_delta, cx)
3125 });
3126 }
3127 }
3128
3129 if let Some(status) = update.status {
3130 compaction.status = status;
3131 }
3132
3133 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3134 }
3135
3136 pub fn can_set_title(&mut self, cx: &mut Context<Self>) -> bool {
3137 self.connection.set_title(&self.session_id, cx).is_some()
3138 }
3139
3140 pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) -> Task<Result<()>> {
3141 let had_provisional = self.provisional_title.take().is_some();
3142 if self.title.as_ref() != Some(&title) {
3143 self.title = Some(title.clone());
3144 cx.emit(AcpThreadEvent::TitleUpdated);
3145 if let Some(set_title) = self.connection.set_title(&self.session_id, cx) {
3146 return set_title.run(title, cx);
3147 }
3148 } else if had_provisional {
3149 cx.emit(AcpThreadEvent::TitleUpdated);
3150 }
3151 Task::ready(Ok(()))
3152 }
3153
3154 /// Sets a provisional display title without propagating back to the
3155 /// underlying agent connection. This is used for quick preview titles
3156 /// (e.g. first 20 chars of the user message) that should be shown
3157 /// immediately but replaced once the LLM generates a proper title via
3158 /// `set_title`.
3159 pub fn set_provisional_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
3160 self.provisional_title = Some(title);
3161 cx.emit(AcpThreadEvent::TitleUpdated);
3162 }
3163
3164 pub fn subagent_spawned(&mut self, session_id: acp::SessionId, cx: &mut Context<Self>) {
3165 cx.emit(AcpThreadEvent::SubagentSpawned(session_id));
3166 }
3167
3168 pub fn update_token_usage(&mut self, usage: Option<TokenUsage>, cx: &mut Context<Self>) {
3169 if usage.is_none() {
3170 self.cost = None;
3171 }
3172 self.token_usage = usage;
3173 cx.emit(AcpThreadEvent::TokenUsageUpdated);
3174 }
3175
3176 pub fn update_retry_status(&mut self, status: RetryStatus, cx: &mut Context<Self>) {
3177 cx.emit(AcpThreadEvent::Retry(status));
3178 }
3179
3180 pub fn update_tool_call(
3181 &mut self,
3182 update: impl Into<ToolCallUpdate>,
3183 cx: &mut Context<Self>,
3184 ) -> Result<()> {
3185 let update = update.into();
3186 let languages = self.project.read(cx).languages().clone();
3187 let path_style = self.project.read(cx).path_style(cx);
3188
3189 let ix = match self.index_for_tool_call(update.id()) {
3190 Some(ix) => ix,
3191 None => {
3192 // Tool call not found - create a failed tool call entry
3193 let failed_tool_call = ToolCall {
3194 id: update.id().clone(),
3195 label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)),
3196 kind: acp::ToolKind::Fetch,
3197 content: vec![ToolCallContent::ContentBlock(ContentBlock::new(
3198 "Tool call not found".into(),
3199 &languages,
3200 path_style,
3201 cx,
3202 ))],
3203 status: ToolCallStatus::Failed,
3204 locations: Vec::new(),
3205 resolved_locations: Vec::new(),
3206 raw_input: None,
3207 raw_input_markdown: None,
3208 raw_output: None,
3209 tool_name: None,
3210 subagent_session_info: None,
3211 sandbox_authorization_details: None,
3212 sandbox_fallback_authorization_details: None,
3213 sandbox_not_applied: None,
3214 };
3215 self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);
3216 return Ok(());
3217 }
3218 };
3219 let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
3220 unreachable!()
3221 };
3222
3223 match update {
3224 ToolCallUpdate::UpdateFields(update) => {
3225 let location_updated = update.fields.locations.is_some();
3226 call.update_fields(
3227 update.fields,
3228 update.meta,
3229 languages,
3230 path_style,
3231 &self.terminals,
3232 cx,
3233 )?;
3234 if location_updated {
3235 self.resolve_locations(update.tool_call_id, cx);
3236 }
3237 }
3238 ToolCallUpdate::UpdateDiff(update) => {
3239 call.content.clear();
3240 call.content.push(ToolCallContent::Diff(update.diff));
3241 }
3242 ToolCallUpdate::UpdateTerminal(update) => {
3243 call.content.clear();
3244 call.content
3245 .push(ToolCallContent::Terminal(update.terminal));
3246 }
3247 }
3248
3249 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3250
3251 Ok(())
3252 }
3253
3254 /// Updates a tool call if id matches an existing entry, otherwise inserts a new one.
3255 pub fn upsert_tool_call(
3256 &mut self,
3257 tool_call: acp::ToolCall,
3258 cx: &mut Context<Self>,
3259 ) -> Result<(), acp::Error> {
3260 let status = tool_call.status.into();
3261 self.upsert_tool_call_inner(tool_call.into(), status, cx)
3262 }
3263
3264 /// Fails if id does not match an existing entry.
3265 pub fn upsert_tool_call_inner(
3266 &mut self,
3267 update: acp::ToolCallUpdate,
3268 status: ToolCallStatus,
3269 cx: &mut Context<Self>,
3270 ) -> Result<(), acp::Error> {
3271 let language_registry = self.project.read(cx).languages().clone();
3272 let path_style = self.project.read(cx).path_style(cx);
3273 let id = update.tool_call_id.clone();
3274
3275 let agent_telemetry_id = self.connection().telemetry_id();
3276 let session = self.session_id();
3277 let parent_session_id = self.parent_session_id();
3278 if let ToolCallStatus::Completed | ToolCallStatus::Failed = status {
3279 let status = if matches!(status, ToolCallStatus::Completed) {
3280 "completed"
3281 } else {
3282 "failed"
3283 };
3284 telemetry::event!(
3285 "Agent Tool Call Completed",
3286 agent_telemetry_id,
3287 session,
3288 parent_session_id,
3289 status
3290 );
3291 }
3292
3293 if let Some(ix) = self.index_for_tool_call(&id) {
3294 let AgentThreadEntry::ToolCall(call) = &mut self.entries[ix] else {
3295 unreachable!()
3296 };
3297
3298 call.update_fields(
3299 update.fields,
3300 update.meta,
3301 language_registry,
3302 path_style,
3303 &self.terminals,
3304 cx,
3305 )?;
3306 call.update_status(status);
3307
3308 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3309 } else {
3310 let call = ToolCall::from_acp(
3311 update.try_into()?,
3312 status,
3313 language_registry,
3314 self.project.read(cx).path_style(cx),
3315 &self.terminals,
3316 cx,
3317 )?;
3318 self.push_entry(AgentThreadEntry::ToolCall(call), cx);
3319 };
3320
3321 self.resolve_locations(id, cx);
3322 Ok(())
3323 }
3324
3325 fn index_for_tool_call(&self, id: &acp::ToolCallId) -> Option<usize> {
3326 self.entries
3327 .iter()
3328 .enumerate()
3329 .rev()
3330 .find_map(|(index, entry)| {
3331 if let AgentThreadEntry::ToolCall(tool_call) = entry
3332 && &tool_call.id == id
3333 {
3334 Some(index)
3335 } else {
3336 None
3337 }
3338 })
3339 }
3340
3341 fn tool_call_mut(&mut self, id: &acp::ToolCallId) -> Option<(usize, &mut ToolCall)> {
3342 // The tool call we are looking for is typically the last one, or very close to the end.
3343 // At the moment, it doesn't seem like a hashmap would be a good fit for this use case.
3344 self.entries
3345 .iter_mut()
3346 .enumerate()
3347 .rev()
3348 .find_map(|(index, tool_call)| {
3349 if let AgentThreadEntry::ToolCall(tool_call) = tool_call
3350 && &tool_call.id == id
3351 {
3352 Some((index, tool_call))
3353 } else {
3354 None
3355 }
3356 })
3357 }
3358
3359 pub fn tool_call(&self, id: &acp::ToolCallId) -> Option<(usize, &ToolCall)> {
3360 self.entries
3361 .iter()
3362 .enumerate()
3363 .rev()
3364 .find_map(|(index, tool_call)| {
3365 if let AgentThreadEntry::ToolCall(tool_call) = tool_call
3366 && &tool_call.id == id
3367 {
3368 Some((index, tool_call))
3369 } else {
3370 None
3371 }
3372 })
3373 }
3374
3375 pub fn tool_call_for_subagent(&self, session_id: &acp::SessionId) -> Option<&ToolCall> {
3376 self.entries.iter().find_map(|entry| match entry {
3377 AgentThreadEntry::ToolCall(tool_call) => {
3378 if let Some(subagent_session_info) = &tool_call.subagent_session_info
3379 && &subagent_session_info.session_id == session_id
3380 {
3381 Some(tool_call)
3382 } else {
3383 None
3384 }
3385 }
3386 _ => None,
3387 })
3388 }
3389
3390 pub fn resolve_locations(&mut self, id: acp::ToolCallId, cx: &mut Context<Self>) {
3391 let project = self.project.clone();
3392 let should_update_agent_location = self.parent_session_id.is_none();
3393 let Some((_, tool_call)) = self.tool_call_mut(&id) else {
3394 return;
3395 };
3396 let task = tool_call.resolve_locations(project, cx);
3397 cx.spawn(async move |this, cx| {
3398 let resolved_locations = task.await;
3399
3400 this.update(cx, |this, cx| {
3401 let project = this.project.clone();
3402
3403 for location in resolved_locations.iter().flatten() {
3404 this.shared_buffers
3405 .insert(location.buffer.clone(), location.buffer.read(cx).snapshot());
3406 }
3407 let Some((ix, tool_call)) = this.tool_call_mut(&id) else {
3408 return;
3409 };
3410
3411 if let Some(Some(location)) = resolved_locations.last() {
3412 project.update(cx, |project, cx| {
3413 let should_ignore = if let Some(agent_location) = project
3414 .agent_location()
3415 .filter(|agent_location| agent_location.buffer == location.buffer)
3416 {
3417 let snapshot = location.buffer.read(cx).snapshot();
3418 let old_position = agent_location.position.to_point(&snapshot);
3419 let new_position = location.position.to_point(&snapshot);
3420
3421 // ignore this so that when we get updates from the edit tool
3422 // the position doesn't reset to the startof line
3423 old_position.row == new_position.row
3424 && old_position.column > new_position.column
3425 } else {
3426 false
3427 };
3428 if !should_ignore && should_update_agent_location {
3429 project.set_agent_location(Some(location.into()), cx);
3430 }
3431 });
3432 }
3433
3434 let resolved_locations = resolved_locations
3435 .iter()
3436 .map(|l| l.as_ref().map(|l| AgentLocation::from(l)))
3437 .collect::<Vec<_>>();
3438
3439 if tool_call.resolved_locations != resolved_locations {
3440 tool_call.resolved_locations = resolved_locations;
3441 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3442 }
3443 })
3444 })
3445 .detach();
3446 }
3447
3448 pub fn request_tool_call_authorization(
3449 &mut self,
3450 tool_call: acp::ToolCallUpdate,
3451 options: PermissionOptions,
3452 kind: AuthorizationKind,
3453 cx: &mut Context<Self>,
3454 ) -> Result<Task<RequestPermissionOutcome>> {
3455 let (tx, rx) = oneshot::channel();
3456
3457 let current_status = self
3458 .tool_call(&tool_call.tool_call_id)
3459 .and_then(|(_, tool_call)| tool_call.status.as_acp_status())
3460 .or(tool_call.fields.status)
3461 .unwrap_or(acp::ToolCallStatus::Pending);
3462 let status = ToolCallStatus::WaitingForConfirmation {
3463 current_status,
3464 options,
3465 respond_tx: tx,
3466 kind,
3467 };
3468
3469 let tool_call_id = tool_call.tool_call_id.clone();
3470 self.upsert_tool_call_inner(tool_call, status, cx)?;
3471 cx.emit(AcpThreadEvent::ToolAuthorizationRequested(
3472 tool_call_id.clone(),
3473 ));
3474
3475 Ok(cx.spawn(async move |this, cx| {
3476 let outcome = match rx.await {
3477 Ok(outcome) => RequestPermissionOutcome::Selected(outcome),
3478 Err(oneshot::Canceled) => RequestPermissionOutcome::Cancelled,
3479 };
3480 this.update(cx, |_this, cx| {
3481 cx.emit(AcpThreadEvent::ToolAuthorizationReceived(tool_call_id))
3482 })
3483 .ok();
3484 outcome
3485 }))
3486 }
3487
3488 pub fn cancel_tool_call_authorization(&mut self, id: &acp::ToolCallId, cx: &mut Context<Self>) {
3489 let Some((ix, call)) = self.tool_call_mut(id) else {
3490 return;
3491 };
3492 if !matches!(call.status, ToolCallStatus::WaitingForConfirmation { .. }) {
3493 return;
3494 }
3495
3496 call.status = ToolCallStatus::Canceled;
3497 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3498 cx.emit(AcpThreadEvent::ToolAuthorizationReceived(id.clone()));
3499 }
3500
3501 pub fn authorize_tool_call(
3502 &mut self,
3503 id: acp::ToolCallId,
3504 outcome: SelectedPermissionOutcome,
3505 cx: &mut Context<Self>,
3506 ) {
3507 let Some((ix, call)) = self.tool_call_mut(&id) else {
3508 return;
3509 };
3510
3511 let new_status =
3512 match &call.status {
3513 ToolCallStatus::WaitingForConfirmation {
3514 kind: AuthorizationKind::ActionChoice,
3515 ..
3516 } => ToolCallStatus::InProgress,
3517 ToolCallStatus::WaitingForConfirmation { current_status, .. } => {
3518 match outcome.option_kind {
3519 acp::PermissionOptionKind::RejectOnce
3520 | acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected,
3521 acp::PermissionOptionKind::AllowOnce
3522 | acp::PermissionOptionKind::AllowAlways => {
3523 ToolCallStatus::status_after_permission_grant(*current_status)
3524 }
3525 _ => ToolCallStatus::status_after_permission_grant(*current_status),
3526 }
3527 }
3528 _ => match outcome.option_kind {
3529 acp::PermissionOptionKind::RejectOnce
3530 | acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected,
3531 acp::PermissionOptionKind::AllowOnce
3532 | acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress,
3533 _ => ToolCallStatus::InProgress,
3534 },
3535 };
3536
3537 let curr_status = mem::replace(&mut call.status, new_status);
3538
3539 if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
3540 respond_tx.send(outcome).ok();
3541 }
3542
3543 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3544 }
3545
3546 pub fn request_elicitation(
3547 &mut self,
3548 request: acp::CreateElicitationRequest,
3549 cx: &mut Context<Self>,
3550 ) -> Result<Task<acp::CreateElicitationResponse>, acp::Error> {
3551 self.request_elicitation_with_id(request, cx)
3552 .map(|(_, task)| task)
3553 }
3554
3555 pub fn request_elicitation_with_id(
3556 &mut self,
3557 request: acp::CreateElicitationRequest,
3558 cx: &mut Context<Self>,
3559 ) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
3560 ElicitationStore::validate_request(&request)?;
3561
3562 let (id, response_rx) = self.elicitations.insert_pending_elicitation(request);
3563 self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx);
3564 cx.emit(AcpThreadEvent::ElicitationRequested(id.clone()));
3565
3566 let task =
3567 ElicitationStore::response_task(id.clone(), response_rx, cx, |_thread, cx, id| {
3568 cx.emit(AcpThreadEvent::ElicitationResponded(id))
3569 });
3570
3571 Ok((id, task))
3572 }
3573
3574 pub fn respond_to_elicitation(
3575 &mut self,
3576 id: &ElicitationEntryId,
3577 response: acp::CreateElicitationResponse,
3578 cx: &mut Context<Self>,
3579 ) {
3580 let Some(ix) = self.elicitation_entry_ix(id) else {
3581 return;
3582 };
3583 if !self.elicitations.respond_to_elicitation_by_id(id, response) {
3584 return;
3585 }
3586
3587 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3588 }
3589
3590 pub fn complete_url_elicitation(
3591 &mut self,
3592 elicitation_id: &acp::ElicitationId,
3593 cx: &mut Context<Self>,
3594 ) {
3595 let Some(entry_id) = self
3596 .elicitations
3597 .entry_id_for_url_elicitation(elicitation_id)
3598 else {
3599 return;
3600 };
3601 let Some(ix) = self.elicitation_entry_ix(&entry_id) else {
3602 return;
3603 };
3604 if !self.elicitations.complete_url_elicitation_by_id(&entry_id) {
3605 return;
3606 }
3607
3608 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3609 }
3610
3611 pub fn cancel_elicitation(&mut self, id: &ElicitationEntryId, cx: &mut Context<Self>) {
3612 let Some(ix) = self.elicitation_entry_ix(id) else {
3613 return;
3614 };
3615 if !self.elicitations.cancel_elicitation_by_id(id, true) {
3616 return;
3617 }
3618
3619 cx.emit(AcpThreadEvent::EntryUpdated(ix));
3620 }
3621
3622 fn elicitation_entry_ix(&self, id: &ElicitationEntryId) -> Option<usize> {
3623 self.entries
3624 .iter()
3625 .enumerate()
3626 .rev()
3627 .find_map(|(index, entry)| {
3628 matches!(entry, AgentThreadEntry::Elicitation(elicitation_id) if elicitation_id == id)
3629 .then_some(index)
3630 })
3631 }
3632
3633 pub fn elicitation(&self, id: &ElicitationEntryId) -> Option<(usize, &Elicitation)> {
3634 let index = self.elicitation_entry_ix(id)?;
3635 let (_, elicitation) = self.elicitations.elicitation(id)?;
3636 Some((index, elicitation))
3637 }
3638
3639 pub fn plan(&self) -> &Plan {
3640 &self.plan
3641 }
3642
3643 pub fn update_plan(&mut self, request: acp::Plan, cx: &mut Context<Self>) {
3644 let new_entries_len = request.entries.len();
3645 let mut new_entries = request.entries.into_iter();
3646
3647 // Reuse existing markdown to prevent flickering
3648 for (old, new) in self.plan.entries.iter_mut().zip(new_entries.by_ref()) {
3649 let PlanEntry {
3650 content,
3651 priority,
3652 status,
3653 } = old;
3654 content.update(cx, |old, cx| {
3655 old.replace(new.content, cx);
3656 });
3657 *priority = new.priority;
3658 *status = new.status;
3659 }
3660 for new in new_entries {
3661 self.plan.entries.push(PlanEntry::from_acp(new, cx))
3662 }
3663 self.plan.entries.truncate(new_entries_len);
3664
3665 cx.notify();
3666 }
3667
3668 pub fn snapshot_completed_plan(&mut self, cx: &mut Context<Self>) {
3669 if !self.plan.is_empty() && self.plan.stats().pending == 0 {
3670 let completed_entries = std::mem::take(&mut self.plan.entries);
3671 self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx);
3672 }
3673 }
3674
3675 fn clear_completed_plan_entries(&mut self, cx: &mut Context<Self>) {
3676 self.plan
3677 .entries
3678 .retain(|entry| !matches!(entry.status, acp::PlanEntryStatus::Completed));
3679 cx.notify();
3680 }
3681
3682 pub fn clear_plan(&mut self, cx: &mut Context<Self>) {
3683 self.plan.entries.clear();
3684 cx.notify();
3685 }
3686
3687 #[cfg(any(test, feature = "test-support"))]
3688 pub fn send_raw(
3689 &mut self,
3690 message: &str,
3691 cx: &mut Context<Self>,
3692 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3693 self.send(vec![message.into()], cx)
3694 }
3695
3696 pub fn send(
3697 &mut self,
3698 message: Vec<acp::ContentBlock>,
3699 cx: &mut Context<Self>,
3700 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3701 self.send_inner(message, true, cx)
3702 }
3703
3704 /// Sends a prompt without displaying a user-message bubble for it.
3705 /// This is used for native slash commands (e.g. `/compact`) that run a turn
3706 /// which produces its own thread entry (like the compaction summary). The
3707 /// typed command isn't sent to the model as an ordinary user turn.
3708 pub fn send_command(
3709 &mut self,
3710 message: Vec<acp::ContentBlock>,
3711 cx: &mut Context<Self>,
3712 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3713 self.send_inner(message, false, cx)
3714 }
3715
3716 fn send_inner(
3717 &mut self,
3718 message: Vec<acp::ContentBlock>,
3719 push_user_message: bool,
3720 cx: &mut Context<Self>,
3721 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3722 let block = ContentBlock::new_combined(
3723 message.clone(),
3724 self.project.read(cx).languages().clone(),
3725 self.project.read(cx).path_style(cx),
3726 cx,
3727 );
3728 let request = acp::PromptRequest::new(self.session_id.clone(), message.clone());
3729 let git_store = self.project.read(cx).git_store().clone();
3730
3731 let client_user_message_ids = self.connection.client_user_message_ids(cx);
3732 let client_id = client_user_message_ids
3733 .as_ref()
3734 .map(|client_user_message_ids| client_user_message_ids.new_id());
3735
3736 self.run_turn(cx, async move |this, cx| {
3737 if push_user_message {
3738 this.update(cx, |this, cx| {
3739 this.push_entry(
3740 AgentThreadEntry::UserMessage(UserMessage {
3741 protocol_id: None,
3742 client_id: client_id.clone(),
3743 is_optimistic: true,
3744 content: block,
3745 chunks: message,
3746 checkpoint: None,
3747 indented: false,
3748 }),
3749 cx,
3750 );
3751 })
3752 .ok();
3753
3754 let old_checkpoint = git_store
3755 .update(cx, |git, cx| git.checkpoint(cx))
3756 .await
3757 .context("failed to get old checkpoint")
3758 .log_err();
3759 this.update(cx, |this, _cx| {
3760 if let Some((_ix, message)) = this.last_user_message() {
3761 message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint {
3762 git_checkpoint,
3763 show: false,
3764 });
3765 }
3766 })
3767 .ok();
3768 }
3769
3770 this.update(cx, |this, cx| {
3771 if let (Some(prompt), Some(client_id)) = (client_user_message_ids, client_id) {
3772 prompt.prompt(client_id, request, cx)
3773 } else {
3774 this.connection.prompt(request, cx)
3775 }
3776 })?
3777 .await
3778 })
3779 }
3780
3781 pub fn can_retry(&self, cx: &App) -> bool {
3782 self.connection.retry(&self.session_id, cx).is_some()
3783 }
3784
3785 pub fn retry(
3786 &mut self,
3787 cx: &mut Context<Self>,
3788 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3789 self.run_turn(cx, async move |this, cx| {
3790 this.update(cx, |this, cx| {
3791 this.connection
3792 .retry(&this.session_id, cx)
3793 .map(|retry| retry.run(cx))
3794 })?
3795 .context("retrying a session is not supported")?
3796 .await
3797 })
3798 }
3799
3800 fn run_turn(
3801 &mut self,
3802 cx: &mut Context<Self>,
3803 f: impl 'static + AsyncFnOnce(WeakEntity<Self>, &mut AsyncApp) -> Result<acp::PromptResponse>,
3804 ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
3805 self.clear_completed_plan_entries(cx);
3806 self.had_error = false;
3807
3808 let (tx, rx) = oneshot::channel();
3809 let cancel_task = self.cancel(cx);
3810
3811 self.turn_id += 1;
3812 let turn_id = self.turn_id;
3813 self.running_turn = Some(RunningTurn {
3814 id: turn_id,
3815 send_task: cx.spawn(async move |this, cx| {
3816 cancel_task.await;
3817 tx.send(f(this, cx).await).ok();
3818 }),
3819 });
3820 cx.emit(AcpThreadEvent::StatusChanged);
3821
3822 cx.spawn(async move |this, cx| {
3823 let response = rx.await;
3824
3825 this.update(cx, |this, cx| this.update_last_checkpoint(cx))?
3826 .await?;
3827
3828 this.update(cx, |this, cx| {
3829 if this.parent_session_id.is_none() {
3830 this.project
3831 .update(cx, |project, cx| project.set_agent_location(None, cx));
3832 }
3833
3834 let is_same_turn = this
3835 .running_turn
3836 .as_ref()
3837 .is_some_and(|turn| turn_id == turn.id);
3838
3839 // If the user submitted a follow up message, running_turn might
3840 // already point to a different turn. Therefore we only want to
3841 // take the task if it's the same turn. We do this before the
3842 // dropped-tx guard below so the panel exits its generating
3843 // state even when the send_task is cancelled before tx.send().
3844 if is_same_turn {
3845 this.running_turn.take();
3846 }
3847
3848 let Ok(response) = response else {
3849 if is_same_turn {
3850 cx.emit(AcpThreadEvent::StatusChanged);
3851 }
3852 // tx dropped, just return
3853 return Ok(None);
3854 };
3855
3856 match response {
3857 Ok(r) => {
3858 Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
3859
3860 if r.stop_reason == acp::StopReason::MaxTokens {
3861 if is_same_turn {
3862 cx.emit(AcpThreadEvent::StatusChanged);
3863 }
3864 this.had_error = true;
3865 cx.emit(AcpThreadEvent::Error);
3866 log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
3867
3868 let exceeded_max_output_tokens =
3869 this.token_usage.as_ref().is_some_and(|u| {
3870 u.max_output_tokens
3871 .is_some_and(|max| u.output_tokens >= max)
3872 });
3873
3874 if exceeded_max_output_tokens {
3875 log::error!(
3876 "Max output tokens reached. Usage: {:?}",
3877 this.token_usage
3878 );
3879 } else {
3880 log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
3881 }
3882 if is_same_turn {
3883 this.cancel_pending_turn_entries(cx);
3884 }
3885 return Err(anyhow!(MaxOutputTokensError));
3886 }
3887
3888 let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled);
3889 if canceled && is_same_turn {
3890 this.cancel_pending_turn_entries(cx);
3891 }
3892
3893 if !canceled {
3894 this.snapshot_completed_plan(cx);
3895 }
3896
3897 // Handle refusal - distinguish between user prompt and tool call refusals
3898 if let acp::StopReason::Refusal = r.stop_reason {
3899 this.had_error = true;
3900 if let Some((user_msg_ix, _)) = this.last_user_message() {
3901 // Check if there's a completed tool call with results after the last user message
3902 // This indicates the refusal is in response to tool output, not the user's prompt
3903 let has_completed_tool_call_after_user_msg =
3904 this.entries.iter().skip(user_msg_ix + 1).any(|entry| {
3905 if let AgentThreadEntry::ToolCall(tool_call) = entry {
3906 // Check if the tool call has completed and has output
3907 matches!(tool_call.status, ToolCallStatus::Completed)
3908 && tool_call.raw_output.is_some()
3909 } else {
3910 false
3911 }
3912 });
3913
3914 if has_completed_tool_call_after_user_msg {
3915 // Refusal is due to tool output - don't truncate, just notify
3916 // The model refused based on what the tool returned
3917 cx.emit(AcpThreadEvent::Refusal);
3918 } else {
3919 // User prompt was refused - truncate back to before the user message
3920 let range = user_msg_ix..this.entries.len();
3921 if range.start < range.end {
3922 this.entries.truncate(user_msg_ix);
3923 cx.emit(AcpThreadEvent::EntriesRemoved(range));
3924 }
3925 cx.emit(AcpThreadEvent::Refusal);
3926 }
3927 } else {
3928 // No user message found, treat as general refusal
3929 cx.emit(AcpThreadEvent::Refusal);
3930 }
3931 }
3932
3933 if cx.has_flag::<AcpBetaFeatureFlag>()
3934 && let Some(response_usage) = &r.usage
3935 {
3936 let usage = this.token_usage.get_or_insert_with(Default::default);
3937 usage.input_tokens = response_usage.input_tokens;
3938 usage.output_tokens = response_usage.output_tokens;
3939 cx.emit(AcpThreadEvent::TokenUsageUpdated);
3940 }
3941
3942 if is_same_turn {
3943 cx.emit(AcpThreadEvent::StatusChanged);
3944 }
3945 cx.emit(AcpThreadEvent::Stopped(r.stop_reason));
3946 Ok(Some(r))
3947 }
3948 Err(e) => {
3949 if is_same_turn {
3950 cx.emit(AcpThreadEvent::StatusChanged);
3951 }
3952 Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
3953 if is_same_turn {
3954 this.cancel_pending_turn_entries(cx);
3955 }
3956 this.had_error = true;
3957 cx.emit(AcpThreadEvent::Error);
3958 log::error!("Error in run turn: {:?}", e);
3959 Err(e)
3960 }
3961 }
3962 })?
3963 })
3964 .boxed()
3965 }
3966
3967 pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<()> {
3968 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
3969 self.cancel_outstanding_elicitations(cx);
3970
3971 let Some(turn) = self.running_turn.take() else {
3972 return Task::ready(());
3973 };
3974 self.mark_pending_entries_as_canceled(cx);
3975 self.connection.cancel(&self.session_id, cx);
3976 cx.emit(AcpThreadEvent::StatusChanged);
3977
3978 // Wait for the send task to complete
3979 cx.background_spawn(turn.send_task)
3980 }
3981
3982 fn cancel_pending_turn_entries(&mut self, cx: &mut Context<Self>) {
3983 self.mark_pending_entries_as_canceled(cx);
3984 self.cancel_outstanding_elicitations(cx);
3985 }
3986
3987 fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context<Self>) {
3988 for (ix, entry) in self.entries.iter_mut().enumerate() {
3989 match entry {
3990 AgentThreadEntry::ToolCall(call) => {
3991 let cancel = matches!(
3992 call.status,
3993 ToolCallStatus::Pending
3994 | ToolCallStatus::WaitingForConfirmation { .. }
3995 | ToolCallStatus::InProgress
3996 );
3997 if cancel {
3998 call.status = ToolCallStatus::Canceled;
3999 cx.emit(AcpThreadEvent::EntryUpdated(ix));
4000 }
4001 }
4002 AgentThreadEntry::ContextCompaction(compaction) => {
4003 if compaction.status == ContextCompactionStatus::InProgress {
4004 compaction.status = ContextCompactionStatus::Canceled;
4005 cx.emit(AcpThreadEvent::EntryUpdated(ix));
4006 }
4007 }
4008 _ => {}
4009 }
4010 }
4011 }
4012
4013 fn cancel_outstanding_elicitations(&mut self, cx: &mut Context<Self>) {
4014 for ix in 0..self.entries.len() {
4015 let Some(AgentThreadEntry::Elicitation(elicitation_id)) = self.entries.get(ix) else {
4016 continue;
4017 };
4018 if self
4019 .elicitations
4020 .cancel_elicitation_by_id(elicitation_id, true)
4021 {
4022 cx.emit(AcpThreadEvent::EntryUpdated(ix));
4023 }
4024 }
4025 }
4026
4027 /// Restores the git working tree to the state at the given checkpoint (if one exists)
4028 pub fn restore_checkpoint(
4029 &mut self,
4030 client_id: ClientUserMessageId,
4031 cx: &mut Context<Self>,
4032 ) -> Task<Result<()>> {
4033 let Some((_, message)) = self.user_message_mut(&client_id) else {
4034 return Task::ready(Err(anyhow!("message not found")));
4035 };
4036
4037 let checkpoint = message
4038 .checkpoint
4039 .as_ref()
4040 .map(|c| c.git_checkpoint.clone());
4041
4042 // Cancel any in-progress generation before restoring
4043 let cancel_task = self.cancel(cx);
4044 let rewind = self.rewind(client_id.clone(), cx);
4045 let git_store = self.project.read(cx).git_store().clone();
4046
4047 cx.spawn(async move |_, cx| {
4048 cancel_task.await;
4049 rewind.await?;
4050 if let Some(checkpoint) = checkpoint {
4051 git_store
4052 .update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))
4053 .await?;
4054 }
4055
4056 Ok(())
4057 })
4058 }
4059
4060 /// Rewinds this thread to before the entry at `index`, removing it and all
4061 /// subsequent entries while rejecting any action_log changes made from that point.
4062 /// Unlike `restore_checkpoint`, this method does not restore from git.
4063 pub fn rewind(
4064 &mut self,
4065 client_id: ClientUserMessageId,
4066 cx: &mut Context<Self>,
4067 ) -> Task<Result<()>> {
4068 let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
4069 return Task::ready(Err(anyhow!("not supported")));
4070 };
4071
4072 Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
4073 let telemetry = ActionLogTelemetry::from(&*self);
4074 cx.spawn(async move |this, cx| {
4075 cx.update(|cx| truncate.run(client_id.clone(), cx)).await?;
4076 this.update(cx, |this, cx| {
4077 if let Some((ix, _)) = this.user_message_mut(&client_id) {
4078 // Collect all terminals from entries that will be removed
4079 let terminals_to_remove: Vec<acp::TerminalId> = this.entries[ix..]
4080 .iter()
4081 .flat_map(|entry| entry.terminals())
4082 .filter_map(|terminal| terminal.read(cx).id().clone().into())
4083 .collect();
4084
4085 let range = ix..this.entries.len();
4086 this.entries.truncate(ix);
4087 cx.emit(AcpThreadEvent::EntriesRemoved(range));
4088
4089 // Kill and remove the terminals
4090 for terminal_id in terminals_to_remove {
4091 if let Some(terminal) = this.terminals.remove(&terminal_id) {
4092 terminal.update(cx, |terminal, cx| {
4093 terminal.kill(cx);
4094 });
4095 }
4096 }
4097 }
4098 this.action_log().update(cx, |action_log, cx| {
4099 action_log.reject_all_edits(Some(telemetry), cx)
4100 })
4101 })?
4102 .await;
4103 Ok(())
4104 })
4105 }
4106
4107 fn update_last_checkpoint_if_changed(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
4108 let Some(turn_id) = self.running_turn.as_ref().map(|turn| turn.id) else {
4109 return Task::ready(Ok(()));
4110 };
4111
4112 let git_store = self.project.read(cx).git_store().clone();
4113
4114 let Some((client_id, checkpoint)) = self.last_user_message().and_then(|(_, message)| {
4115 let id = message.client_id.clone()?;
4116 let checkpoint = message.checkpoint.as_ref()?;
4117 Some((id, checkpoint))
4118 }) else {
4119 return Task::ready(Ok(()));
4120 };
4121 if checkpoint.show {
4122 return Task::ready(Ok(()));
4123 }
4124 let old_checkpoint = checkpoint.git_checkpoint.clone();
4125
4126 let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
4127 cx.spawn(async move |this, cx| {
4128 let Some(new_checkpoint) = new_checkpoint
4129 .await
4130 .context("failed to get new checkpoint")
4131 .log_err()
4132 else {
4133 return Ok(());
4134 };
4135
4136 let Some(equal) = git_store
4137 .update(cx, |git, cx| {
4138 git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
4139 })
4140 .await
4141 .context("failed to compare checkpoints")
4142 .log_err()
4143 else {
4144 return Ok(());
4145 };
4146
4147 if equal {
4148 return Ok(());
4149 }
4150
4151 this.update(cx, |this, cx| {
4152 if !this
4153 .running_turn
4154 .as_ref()
4155 .is_some_and(|turn| turn.id == turn_id)
4156 {
4157 return;
4158 }
4159
4160 let Some((ix, message)) = this.last_user_message() else {
4161 return;
4162 };
4163 if message.client_id.as_ref() != Some(&client_id) {
4164 return;
4165 }
4166 if let Some(checkpoint) = message.checkpoint.as_mut()
4167 && !checkpoint.show
4168 {
4169 checkpoint.show = true;
4170 cx.emit(AcpThreadEvent::EntryUpdated(ix));
4171 }
4172 })?;
4173
4174 Ok(())
4175 })
4176 }
4177
4178 fn update_last_checkpoint(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
4179 let git_store = self.project.read(cx).git_store().clone();
4180
4181 let Some((_, message)) = self.last_user_message() else {
4182 return Task::ready(Ok(()));
4183 };
4184 let Some(client_id) = message.client_id.clone() else {
4185 return Task::ready(Ok(()));
4186 };
4187 let Some(checkpoint) = message.checkpoint.as_ref() else {
4188 return Task::ready(Ok(()));
4189 };
4190 let old_checkpoint = checkpoint.git_checkpoint.clone();
4191
4192 let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
4193 cx.spawn(async move |this, cx| {
4194 let Some(new_checkpoint) = new_checkpoint
4195 .await
4196 .context("failed to get new checkpoint")
4197 .log_err()
4198 else {
4199 return Ok(());
4200 };
4201
4202 let Some(equal) = git_store
4203 .update(cx, |git, cx| {
4204 git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
4205 })
4206 .await
4207 .context("failed to compare checkpoints")
4208 .log_err()
4209 else {
4210 return Ok(());
4211 };
4212
4213 this.update(cx, |this, cx| {
4214 if let Some((ix, message)) = this.user_message_mut(&client_id) {
4215 if let Some(checkpoint) = message.checkpoint.as_mut() {
4216 checkpoint.show = !equal;
4217 cx.emit(AcpThreadEvent::EntryUpdated(ix));
4218 }
4219 }
4220 })?;
4221
4222 Ok(())
4223 })
4224 }
4225
4226 fn last_user_message(&mut self) -> Option<(usize, &mut UserMessage)> {
4227 self.entries
4228 .iter_mut()
4229 .enumerate()
4230 .rev()
4231 .find_map(|(ix, entry)| {
4232 if let AgentThreadEntry::UserMessage(message) = entry {
4233 Some((ix, message))
4234 } else {
4235 None
4236 }
4237 })
4238 }
4239
4240 fn user_message_mut(
4241 &mut self,
4242 client_id: &ClientUserMessageId,
4243 ) -> Option<(usize, &mut UserMessage)> {
4244 self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
4245 if let AgentThreadEntry::UserMessage(message) = entry {
4246 if message.client_id.as_ref() == Some(client_id) {
4247 Some((ix, message))
4248 } else {
4249 None
4250 }
4251 } else {
4252 None
4253 }
4254 })
4255 }
4256
4257 pub fn read_text_file(
4258 &self,
4259 path: PathBuf,
4260 line: Option<u32>,
4261 limit: Option<u32>,
4262 reuse_shared_snapshot: bool,
4263 cx: &mut Context<Self>,
4264 ) -> Task<Result<String, acp::Error>> {
4265 // Args are 1-based, move to 0-based
4266 let line = line.unwrap_or_default().saturating_sub(1);
4267 let limit = limit.unwrap_or(u32::MAX);
4268 let project = self.project.clone();
4269 let action_log = self.action_log.clone();
4270 let should_update_agent_location = self.parent_session_id.is_none();
4271 cx.spawn(async move |this, cx| {
4272 let load = project.update(cx, |project, cx| {
4273 let path = project
4274 .project_path_for_absolute_path(&path, cx)
4275 .ok_or_else(|| {
4276 acp::Error::resource_not_found(Some(path.display().to_string()))
4277 })?;
4278 Ok::<_, acp::Error>(project.open_buffer(path, cx))
4279 })?;
4280
4281 let buffer = load.await?;
4282
4283 let snapshot = if reuse_shared_snapshot {
4284 this.read_with(cx, |this, _| {
4285 this.shared_buffers.get(&buffer.clone()).cloned()
4286 })
4287 .log_err()
4288 .flatten()
4289 } else {
4290 None
4291 };
4292
4293 let snapshot = if let Some(snapshot) = snapshot {
4294 snapshot
4295 } else {
4296 action_log.update(cx, |action_log, cx| {
4297 action_log.buffer_read(buffer.clone(), cx);
4298 });
4299
4300 let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
4301 this.update(cx, |this, _| {
4302 this.shared_buffers.insert(buffer.clone(), snapshot.clone());
4303 })?;
4304 snapshot
4305 };
4306
4307 let max_point = snapshot.max_point();
4308 let start_position = Point::new(line, 0);
4309
4310 if start_position > max_point {
4311 return Err(acp::Error::invalid_params().data(format!(
4312 "Attempting to read beyond the end of the file, line {}:{}",
4313 max_point.row + 1,
4314 max_point.column
4315 )));
4316 }
4317
4318 let start = snapshot.anchor_before(start_position);
4319 let end = snapshot.anchor_before(Point::new(line.saturating_add(limit), 0));
4320
4321 if should_update_agent_location {
4322 project.update(cx, |project, cx| {
4323 project.set_agent_location(
4324 Some(AgentLocation {
4325 buffer: buffer.downgrade(),
4326 position: start,
4327 }),
4328 cx,
4329 );
4330 });
4331 }
4332
4333 Ok(snapshot.text_for_range(start..end).collect::<String>())
4334 })
4335 }
4336
4337 pub fn write_text_file(
4338 &self,
4339 path: PathBuf,
4340 content: String,
4341 cx: &mut Context<Self>,
4342 ) -> Task<Result<()>> {
4343 let project = self.project.clone();
4344 let action_log = self.action_log.clone();
4345 let should_update_agent_location = self.parent_session_id.is_none();
4346 cx.spawn(async move |this, cx| {
4347 let load = project.update(cx, |project, cx| {
4348 let path = project
4349 .project_path_for_absolute_path(&path, cx)
4350 .context("invalid path")?;
4351 anyhow::Ok(project.open_buffer(path, cx))
4352 });
4353 let buffer = load?.await?;
4354 let snapshot = this.update(cx, |this, cx| {
4355 this.shared_buffers
4356 .get(&buffer)
4357 .cloned()
4358 .unwrap_or_else(|| buffer.read(cx).snapshot())
4359 })?;
4360 let edits = cx
4361 .background_executor()
4362 .spawn(async move {
4363 let old_text = snapshot.text();
4364 text_diff(old_text.as_str(), &content)
4365 .into_iter()
4366 .map(|(range, replacement)| {
4367 (snapshot.anchor_range_inside(range), replacement)
4368 })
4369 .collect::<Vec<_>>()
4370 })
4371 .await;
4372
4373 if should_update_agent_location {
4374 project.update(cx, |project, cx| {
4375 project.set_agent_location(
4376 Some(AgentLocation {
4377 buffer: buffer.downgrade(),
4378 position: edits
4379 .last()
4380 .map(|(range, _)| range.end)
4381 .unwrap_or(Anchor::min_for_buffer(buffer.read(cx).remote_id())),
4382 }),
4383 cx,
4384 );
4385 });
4386 }
4387
4388 let format_on_save = cx.update(|cx| {
4389 action_log.update(cx, |action_log, cx| {
4390 action_log.buffer_read(buffer.clone(), cx);
4391 });
4392
4393 let format_on_save = buffer.update(cx, |buffer, cx| {
4394 buffer.start_transaction();
4395 buffer.edit(edits, None, cx);
4396 buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
4397
4398 let settings =
4399 language::language_settings::LanguageSettings::for_buffer(buffer, cx);
4400
4401 settings.format_on_save != FormatOnSave::Off
4402 });
4403 action_log.update(cx, |action_log, cx| {
4404 action_log.buffer_edited(buffer.clone(), cx);
4405 });
4406 format_on_save
4407 });
4408
4409 if format_on_save {
4410 let format_task = project.update(cx, |project, cx| {
4411 project.format(
4412 HashSet::from_iter([buffer.clone()]),
4413 LspFormatTarget::Buffers,
4414 false,
4415 FormatTrigger::Save,
4416 cx,
4417 )
4418 });
4419 format_task.await.log_err();
4420
4421 action_log.update(cx, |action_log, cx| {
4422 action_log.buffer_edited(buffer.clone(), cx);
4423 });
4424 }
4425
4426 project
4427 .update(cx, |project, cx| project.save_buffer(buffer, cx))
4428 .await
4429 })
4430 }
4431
4432 pub fn create_terminal(
4433 &self,
4434 command: String,
4435 args: Vec<String>,
4436 extra_env: Vec<acp::EnvVariable>,
4437 cwd: Option<PathBuf>,
4438 output_byte_limit: Option<u64>,
4439 sandbox_wrap: Option<SandboxWrap>,
4440 cx: &mut Context<Self>,
4441 ) -> Task<Result<Entity<Terminal>>> {
4442 let env = match &cwd {
4443 Some(dir) => self.project.update(cx, |project, cx| {
4444 project.environment().update(cx, |env, cx| {
4445 env.directory_environment(dir.as_path().into(), cx)
4446 })
4447 }),
4448 None => Task::ready(None).shared(),
4449 };
4450 let env = cx.spawn(async move |_, _| {
4451 let mut env = env.await.unwrap_or_default();
4452 // Disables paging for `git` and hopefully other commands
4453 env.insert("PAGER".into(), "".into());
4454 for var in extra_env {
4455 env.insert(var.name, var.value);
4456 }
4457 env
4458 });
4459
4460 let project = self.project.clone();
4461 let language_registry = project.read(cx).languages().clone();
4462 let is_windows = project.read(cx).path_style(cx).is_windows();
4463 // Headless hosts (e.g. the eval CLI) have no controlling TTY, so PTY
4464 // setup fails with `ENOTTY`. Run the command non-interactively and
4465 // without a PTY in that case.
4466 let headless = HeadlessTerminal::is_enabled(cx);
4467
4468 let terminal_id = acp::TerminalId::new(Uuid::new_v4().to_string());
4469 let terminal_task = cx.spawn({
4470 let terminal_id = terminal_id.clone();
4471 async move |_this, cx| {
4472 let env = env.await;
4473 let shell = project
4474 .update(cx, |project, cx| {
4475 project
4476 .remote_client()
4477 .and_then(|r| r.read(cx).default_system_shell())
4478 })
4479 .unwrap_or_else(|| get_default_system_shell_preferring_bash());
4480
4481 // The sandbox owns the network proxy (for restricted-network
4482 // policies) and injects the child's proxy env vars, returning
4483 // the env to spawn with. On Windows, restricted host access is
4484 // rejected inside the sandbox before command preparation.
4485 #[cfg(target_os = "windows")]
4486 let (task_command, task_args, task_env, sandbox, spawn_cwd) =
4487 if sandbox_wrap.is_some() {
4488 let (task_command, task_args) = task::ShellBuilder::new(
4489 &Shell::Program("/bin/sh".to_string()),
4490 false,
4491 )
4492 .non_interactive()
4493 .redirect_stdin_to_dev_null()
4494 .build(Some(command.clone()), &args);
4495 let wrap = cx.background_spawn(prepare_sandbox_wrap(
4496 task_command,
4497 task_args,
4498 cwd.clone(),
4499 sandbox_wrap,
4500 env,
4501 ));
4502 let timeout = cx.background_executor().timer(WSL_SANDBOX_WRAP_TIMEOUT);
4503 let (task_command, task_args, task_env, sandbox) = futures::select_biased! {
4504 result = wrap.fuse() => result?,
4505 _ = timeout.fuse() => return Err(anyhow::Error::new(
4506 sandbox::SandboxError::WslUnavailable(format!(
4507 "WSL did not respond within {} seconds while preparing the sandboxed command",
4508 WSL_SANDBOX_WRAP_TIMEOUT.as_secs()
4509 )),
4510 )),
4511 };
4512 (task_command, task_args, task_env, sandbox, None)
4513 } else {
4514 // No sandbox wrap means we're running unsandboxed, and
4515 // on Windows that deliberately changes the shell: the
4516 // sandboxed path runs under WSL's Linux bash, but this
4517 // fallback uses the host's `shell` against the native cwd.
4518 let mut builder = ShellBuilder::new(&Shell::Program(shell), is_windows);
4519 if headless {
4520 builder = builder.non_interactive();
4521 }
4522 let (task_command, task_args) = builder
4523 .redirect_stdin_to_dev_null()
4524 .build(Some(command.clone()), &args);
4525 (task_command, task_args, env, None, cwd.clone())
4526 };
4527
4528 #[cfg(not(target_os = "windows"))]
4529 let (task_command, task_args, task_env, sandbox, spawn_cwd) = {
4530 let mut builder = ShellBuilder::new(&Shell::Program(shell), is_windows);
4531 if headless {
4532 builder = builder.non_interactive();
4533 }
4534 let (task_command, task_args) = builder
4535 .redirect_stdin_to_dev_null()
4536 .build(Some(command.clone()), &args);
4537 let (task_command, task_args, task_env, sandbox) = cx
4538 .background_spawn(prepare_sandbox_wrap(
4539 task_command,
4540 task_args,
4541 cwd.clone(),
4542 sandbox_wrap,
4543 env,
4544 ))
4545 .await?;
4546 (task_command, task_args, task_env, sandbox, cwd.clone())
4547 };
4548 let terminal = project
4549 .update(cx, |project, cx| {
4550 project.create_terminal_task(
4551 task::SpawnInTerminal {
4552 command: Some(task_command),
4553 args: task_args,
4554 cwd: spawn_cwd,
4555 env: task_env,
4556 ..Default::default()
4557 },
4558 cx,
4559 )
4560 })
4561 .await?;
4562
4563 anyhow::Ok(cx.new(|cx| {
4564 Terminal::new(
4565 terminal_id,
4566 &format!("{} {}", command, args.join(" ")),
4567 cwd,
4568 output_byte_limit.map(|l| l as usize),
4569 terminal,
4570 language_registry,
4571 sandbox,
4572 cx,
4573 )
4574 }))
4575 }
4576 });
4577
4578 cx.spawn(async move |this, cx| {
4579 let terminal = terminal_task.await?;
4580 this.update(cx, |this, _cx| {
4581 this.terminals.insert(terminal_id, terminal.clone());
4582 terminal
4583 })
4584 })
4585 }
4586
4587 pub fn kill_terminal(
4588 &mut self,
4589 terminal_id: acp::TerminalId,
4590 cx: &mut Context<Self>,
4591 ) -> Result<()> {
4592 self.terminals
4593 .get(&terminal_id)
4594 .context("Terminal not found")?
4595 .update(cx, |terminal, cx| {
4596 terminal.kill(cx);
4597 });
4598
4599 Ok(())
4600 }
4601
4602 pub fn release_terminal(
4603 &mut self,
4604 terminal_id: acp::TerminalId,
4605 cx: &mut Context<Self>,
4606 ) -> Result<()> {
4607 self.terminals
4608 .remove(&terminal_id)
4609 .context("Terminal not found")?
4610 .update(cx, |terminal, cx| {
4611 terminal.kill(cx);
4612 });
4613
4614 Ok(())
4615 }
4616
4617 pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result<Entity<Terminal>> {
4618 self.terminals
4619 .get(&terminal_id)
4620 .context("Terminal not found")
4621 .cloned()
4622 }
4623
4624 pub fn to_markdown(&self, cx: &App) -> String {
4625 self.entries
4626 .iter()
4627 .map(|entry| match entry {
4628 AgentThreadEntry::Elicitation(elicitation_id) => self
4629 .elicitations
4630 .elicitation(elicitation_id)
4631 .map(|(_, elicitation)| {
4632 format!("## Input Requested\n\n{}\n\n", elicitation.request.message)
4633 })
4634 .unwrap_or_else(|| entry.to_markdown(cx)),
4635 _ => entry.to_markdown(cx),
4636 })
4637 .collect()
4638 }
4639
4640 pub fn emit_load_error(&mut self, error: LoadError, cx: &mut Context<Self>) {
4641 cx.emit(AcpThreadEvent::LoadError(error));
4642 }
4643
4644 pub fn register_terminal_created(
4645 &mut self,
4646 terminal_id: acp::TerminalId,
4647 command_label: String,
4648 working_dir: Option<PathBuf>,
4649 output_byte_limit: Option<u64>,
4650 terminal: Entity<::terminal::Terminal>,
4651 cx: &mut Context<Self>,
4652 ) -> Entity<Terminal> {
4653 let language_registry = self.project.read(cx).languages().clone();
4654
4655 let entity = cx.new(|cx| {
4656 Terminal::new(
4657 terminal_id.clone(),
4658 &command_label,
4659 working_dir.clone(),
4660 output_byte_limit.map(|l| l as usize),
4661 terminal,
4662 language_registry,
4663 // External terminal providers manage their own sandboxing
4664 // (if any). We don't wrap their commands.
4665 None,
4666 cx,
4667 )
4668 });
4669 self.terminals.insert(terminal_id.clone(), entity.clone());
4670 entity
4671 }
4672
4673 pub fn mark_as_subagent_output(&mut self, cx: &mut Context<Self>) {
4674 for entry in self.entries.iter_mut().rev() {
4675 if let AgentThreadEntry::AssistantMessage(assistant_message) = entry {
4676 assistant_message.is_subagent_output = true;
4677 cx.notify();
4678 return;
4679 }
4680 }
4681 }
4682
4683 pub fn on_terminal_provider_event(
4684 &mut self,
4685 event: TerminalProviderEvent,
4686 cx: &mut Context<Self>,
4687 ) {
4688 match event {
4689 TerminalProviderEvent::Created {
4690 terminal_id,
4691 label,
4692 cwd,
4693 output_byte_limit,
4694 terminal,
4695 } => {
4696 let entity = self.register_terminal_created(
4697 terminal_id.clone(),
4698 label,
4699 cwd,
4700 output_byte_limit,
4701 terminal,
4702 cx,
4703 );
4704
4705 if let Some(mut chunks) = self.pending_terminal_output.remove(&terminal_id) {
4706 for data in chunks.drain(..) {
4707 entity.update(cx, |term, cx| {
4708 term.inner().update(cx, |inner, cx| {
4709 inner.write_output(&data, cx);
4710 })
4711 });
4712 }
4713 }
4714
4715 if let Some(_status) = self.pending_terminal_exit.remove(&terminal_id) {
4716 entity.update(cx, |term, cx| {
4717 term.inner().update(cx, |inner, _| inner.shrink_to_used());
4718 cx.notify();
4719 });
4720 }
4721
4722 cx.notify();
4723 }
4724 TerminalProviderEvent::Output { terminal_id, data } => {
4725 if let Some(entity) = self.terminals.get(&terminal_id) {
4726 entity.update(cx, |term, cx| {
4727 term.inner().update(cx, |inner, cx| {
4728 inner.write_output(&data, cx);
4729 })
4730 });
4731 } else {
4732 self.pending_terminal_output
4733 .entry(terminal_id)
4734 .or_default()
4735 .push(data);
4736 }
4737 }
4738 TerminalProviderEvent::TitleChanged { terminal_id, title } => {
4739 if let Some(entity) = self.terminals.get(&terminal_id) {
4740 entity.update(cx, |term, cx| {
4741 term.inner().update(cx, |inner, cx| {
4742 inner.breadcrumb_text = title;
4743 cx.emit(::terminal::Event::BreadcrumbsChanged);
4744 })
4745 });
4746 }
4747 }
4748 TerminalProviderEvent::Exit {
4749 terminal_id,
4750 status,
4751 } => {
4752 if let Some(entity) = self.terminals.get(&terminal_id) {
4753 entity.update(cx, |term, cx| {
4754 term.inner().update(cx, |inner, _| inner.shrink_to_used());
4755 cx.notify();
4756 });
4757 } else {
4758 self.pending_terminal_exit.insert(terminal_id, status);
4759 }
4760 }
4761 }
4762 }
4763}
4764
4765fn markdown_for_raw_output(
4766 raw_output: &serde_json::Value,
4767 language_registry: &Arc<LanguageRegistry>,
4768 cx: &mut App,
4769) -> Option<Entity<Markdown>> {
4770 match raw_output {
4771 serde_json::Value::Null => None,
4772 serde_json::Value::Bool(value) => Some(cx.new(|cx| {
4773 Markdown::new(
4774 value.to_string().into(),
4775 Some(language_registry.clone()),
4776 None,
4777 cx,
4778 )
4779 })),
4780 serde_json::Value::Number(value) => Some(cx.new(|cx| {
4781 Markdown::new(
4782 value.to_string().into(),
4783 Some(language_registry.clone()),
4784 None,
4785 cx,
4786 )
4787 })),
4788 serde_json::Value::String(value) => Some(cx.new(|cx| {
4789 Markdown::new(
4790 value.clone().into(),
4791 Some(language_registry.clone()),
4792 None,
4793 cx,
4794 )
4795 })),
4796 value => Some(cx.new(|cx| {
4797 let pretty_json = to_string_pretty(value).unwrap_or_else(|_| value.to_string());
4798
4799 Markdown::new(
4800 format!("```json\n{}\n```", pretty_json).into(),
4801 Some(language_registry.clone()),
4802 None,
4803 cx,
4804 )
4805 })),
4806 }
4807}
4808
4809#[cfg(test)]
4810mod tests {
4811 use super::*;
4812 use anyhow::anyhow;
4813 use feature_flags::FeatureFlag as _;
4814 use futures::stream::StreamExt as _;
4815 use futures::{channel::mpsc, future::LocalBoxFuture, select};
4816 use gpui::UpdateGlobal as _;
4817 use gpui::{App, AsyncApp, TestAppContext, WeakEntity};
4818 use indoc::indoc;
4819 use project::{AgentId, FakeFs, Fs, RemoveOptions};
4820 use rand::{distr, prelude::*};
4821 use serde_json::json;
4822 use settings::SettingsStore;
4823 use std::{
4824 any::Any,
4825 cell::RefCell,
4826 path::Path,
4827 rc::Rc,
4828 sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
4829 time::Duration,
4830 };
4831 use util::{path, path_list::PathList};
4832
4833 #[test]
4834 fn command_category_meta_round_trips() {
4835 // Exhaustive list of variants. The match below has no wildcard arm, so
4836 // adding a `CommandCategory` variant fails to compile here until it's
4837 // covered, keeping the `as_str`/`from_str` wire contract in sync.
4838 let all = [CommandCategory::Native, CommandCategory::Mcp];
4839 for category in all {
4840 match category {
4841 CommandCategory::Native | CommandCategory::Mcp => {}
4842 }
4843 let meta = meta_with_command_category(category);
4844 assert_eq!(command_category_from_meta(&Some(meta)), Some(category));
4845 }
4846
4847 // Absent meta and unknown categories both decode to `None`.
4848 assert_eq!(command_category_from_meta(&None), None);
4849 let unknown =
4850 acp::Meta::from_iter([(COMMAND_CATEGORY_META_KEY.into(), "future-category".into())]);
4851 assert_eq!(command_category_from_meta(&Some(unknown)), None);
4852 }
4853
4854 #[test]
4855 fn client_user_message_id_serializes_as_string() {
4856 let serialized =
4857 serde_json::to_value(ClientUserMessageId::new()).expect("serialize client message id");
4858 assert!(
4859 serialized.is_string(),
4860 "expected string, got {serialized:?}"
4861 );
4862
4863 let deserialized: ClientUserMessageId =
4864 serde_json::from_value(json!("client-id")).expect("deserialize client message id");
4865 assert_eq!(
4866 serde_json::to_value(deserialized).expect("serialize client message id"),
4867 json!("client-id")
4868 );
4869 }
4870
4871 fn init_test(cx: &mut TestAppContext) {
4872 env_logger::try_init().ok();
4873 cx.update(|cx| {
4874 let mut settings_store = SettingsStore::test(cx);
4875 settings_store.register_setting::<feature_flags::FeatureFlagsSettings>();
4876 cx.set_global(settings_store);
4877 });
4878 }
4879
4880 fn enable_acp_beta(cx: &mut TestAppContext) {
4881 cx.update(|cx| {
4882 cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
4883 });
4884 }
4885
4886 fn set_acp_beta_override(value: &str, cx: &mut TestAppContext) {
4887 cx.update(|cx| {
4888 SettingsStore::update_global(cx, |store, cx| {
4889 store.update_user_settings(cx, |content| {
4890 content
4891 .feature_flags
4892 .get_or_insert_default()
4893 .insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
4894 });
4895 });
4896 });
4897 }
4898
4899 #[test]
4900 fn text_resource_markdown_uses_mime_type_for_code_blocks() {
4901 let shell = acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview")
4902 .mime_type("text/x-shellscript".to_string());
4903 assert_eq!(
4904 ContentBlock::text_resource_markdown(&shell),
4905 "```sh\necho 'hello from exec test'\n```"
4906 );
4907
4908 let markdown = acp::TextResourceContents::new("**approval** requested", "tool://preview")
4909 .mime_type("text/markdown".to_string());
4910 assert_eq!(
4911 ContentBlock::text_resource_markdown(&markdown),
4912 "**approval** requested"
4913 );
4914
4915 let plain = acp::TextResourceContents::new("plain preview", "tool://preview")
4916 .mime_type("text/plain".to_string());
4917 assert_eq!(
4918 ContentBlock::text_resource_markdown(&plain),
4919 "```\nplain preview\n```"
4920 );
4921
4922 let cpp = acp::TextResourceContents::new("int main() {}", "tool://preview")
4923 .mime_type("text/x-c++; charset=utf-8".to_string());
4924 assert_eq!(
4925 ContentBlock::text_resource_markdown(&cpp),
4926 "```cpp\nint main() {}\n```"
4927 );
4928
4929 let untyped = acp::TextResourceContents::new("# plain preview", "tool://preview");
4930 assert_eq!(
4931 ContentBlock::text_resource_markdown(&untyped),
4932 "```\n# plain preview\n```"
4933 );
4934 }
4935
4936 #[gpui::test]
4937 async fn test_tool_call_content_preserves_embedded_text_resource(
4938 cx: &mut gpui::TestAppContext,
4939 ) {
4940 init_test(cx);
4941
4942 cx.update(|cx| {
4943 let language_registry =
4944 Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
4945 let content = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
4946 acp::EmbeddedResourceResource::TextResourceContents(
4947 acp::TextResourceContents::new("echo 'hello from exec test'", "tool://preview")
4948 .mime_type("text/x-shellscript".to_string()),
4949 ),
4950 ));
4951
4952 let block = ContentBlock::new_tool_call_content(
4953 content,
4954 &language_registry,
4955 PathStyle::local(),
4956 cx,
4957 );
4958
4959 let ContentBlock::EmbeddedResource { resource, markdown } = &block else {
4960 panic!("expected embedded resource block, got {block:?}");
4961 };
4962 match &resource.resource {
4963 acp::EmbeddedResourceResource::TextResourceContents(text) => {
4964 assert_eq!(text.text, "echo 'hello from exec test'");
4965 assert_eq!(text.uri, "tool://preview");
4966 assert_eq!(text.mime_type.as_deref(), Some("text/x-shellscript"));
4967 }
4968 other => panic!("expected text resource contents, got {other:?}"),
4969 }
4970
4971 let markdown = markdown
4972 .as_ref()
4973 .expect("text resources should have renderable markdown")
4974 .read(cx)
4975 .source()
4976 .to_string();
4977 assert_eq!(markdown, "```sh\necho 'hello from exec test'\n```");
4978 assert_eq!(
4979 block.to_markdown(cx),
4980 "```sh\necho 'hello from exec test'\n```"
4981 );
4982 assert_eq!(block.text_content(cx), Some("echo 'hello from exec test'"));
4983
4984 let untyped = ContentBlock::new_tool_call_content(
4985 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
4986 acp::EmbeddedResourceResource::TextResourceContents(
4987 acp::TextResourceContents::new("# plain preview", "tool://preview"),
4988 ),
4989 )),
4990 &language_registry,
4991 PathStyle::local(),
4992 cx,
4993 );
4994 assert_eq!(untyped.to_markdown(cx), "```\n# plain preview\n```");
4995 assert_eq!(untyped.text_content(cx), Some("# plain preview"));
4996 });
4997 }
4998
4999 #[gpui::test]
5000 async fn test_tool_call_content_renders_embedded_image_blob_resource(
5001 cx: &mut gpui::TestAppContext,
5002 ) {
5003 init_test(cx);
5004
5005 cx.update(|cx| {
5006 let language_registry =
5007 Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
5008 let image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
5009 acp::EmbeddedResourceResource::BlobResourceContents(
5010 acp::BlobResourceContents::new(
5011 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
5012 "tool://preview.png",
5013 )
5014 .mime_type("image/png".to_string()),
5015 ),
5016 ));
5017
5018 let block = ContentBlock::new_tool_call_content(
5019 image_blob,
5020 &language_registry,
5021 PathStyle::local(),
5022 cx,
5023 );
5024
5025 let ContentBlock::Image { image, dimensions } = &block else {
5026 panic!("expected image block, got {block:?}");
5027 };
5028 assert_eq!(image.format(), gpui::ImageFormat::Png);
5029 assert_eq!(
5030 dimensions.as_ref().map(|size| (size.width, size.height)),
5031 Some((1, 1))
5032 );
5033 assert_eq!(block.to_markdown(cx), "`Image`");
5034 assert_eq!(block.text_content(cx), None);
5035 });
5036 }
5037
5038 #[gpui::test]
5039 async fn test_tool_call_content_falls_back_for_non_image_blob_resource(
5040 cx: &mut gpui::TestAppContext,
5041 ) {
5042 init_test(cx);
5043
5044 cx.update(|cx| {
5045 let language_registry =
5046 Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
5047 let archive_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
5048 acp::EmbeddedResourceResource::BlobResourceContents(
5049 acp::BlobResourceContents::new("not an image", "tool://archive.bin")
5050 .mime_type("application/octet-stream".to_string()),
5051 ),
5052 ));
5053
5054 let block = ContentBlock::new_tool_call_content(
5055 archive_blob,
5056 &language_registry,
5057 PathStyle::local(),
5058 cx,
5059 );
5060
5061 let ContentBlock::EmbeddedResource { resource, markdown } = &block else {
5062 panic!("expected embedded resource block, got {block:?}");
5063 };
5064 assert!(markdown.is_none());
5065 match &resource.resource {
5066 acp::EmbeddedResourceResource::BlobResourceContents(blob) => {
5067 assert_eq!(blob.uri, "tool://archive.bin");
5068 assert_eq!(blob.mime_type.as_deref(), Some("application/octet-stream"));
5069 }
5070 other => panic!("expected blob resource contents, got {other:?}"),
5071 }
5072 assert_eq!(block.to_markdown(cx), "tool://archive.bin");
5073 assert_eq!(block.text_content(cx), None);
5074
5075 let invalid_image_blob = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
5076 acp::EmbeddedResourceResource::BlobResourceContents(
5077 acp::BlobResourceContents::new("not-base64", "tool://preview.png")
5078 .mime_type("image/png".to_string()),
5079 ),
5080 ));
5081 let invalid = ContentBlock::new_tool_call_content(
5082 invalid_image_blob,
5083 &language_registry,
5084 PathStyle::local(),
5085 cx,
5086 );
5087 let ContentBlock::EmbeddedResource { resource, markdown } = &invalid else {
5088 panic!("expected embedded resource block, got {invalid:?}");
5089 };
5090 assert!(markdown.is_none());
5091 assert_eq!(
5092 ContentBlock::embedded_resource_label(resource),
5093 "tool://preview.png"
5094 );
5095 assert_eq!(invalid.to_markdown(cx), "tool://preview.png");
5096 });
5097 }
5098
5099 #[test]
5100 fn sandbox_authorization_details_deserialize_legacy_network_bool() {
5101 // Older builds persisted `network: bool`; the `alias` on
5102 // `network_all_hosts` must keep those details rendering as a
5103 // network request rather than silently dropping it.
5104 let details: SandboxAuthorizationDetails =
5105 serde_json::from_value(json!({ "network": true })).unwrap();
5106 assert!(details.network_all_hosts);
5107 assert!(details.network_hosts.is_empty());
5108
5109 let details: SandboxAuthorizationDetails =
5110 serde_json::from_value(json!({ "network": false })).unwrap();
5111 assert!(!details.network_all_hosts);
5112 }
5113
5114 #[gpui::test]
5115 async fn test_terminal_output_buffered_before_created_renders(cx: &mut gpui::TestAppContext) {
5116 init_test(cx);
5117
5118 let fs = FakeFs::new(cx.executor());
5119 let project = Project::test(fs, [], cx).await;
5120 let connection = Rc::new(FakeAgentConnection::new());
5121 let thread = cx
5122 .update(|cx| {
5123 connection.new_session(
5124 project,
5125 PathList::new(&[std::path::Path::new(path!("/test"))]),
5126 cx,
5127 )
5128 })
5129 .await
5130 .unwrap();
5131
5132 let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
5133
5134 // Send Output BEFORE Created - should be buffered by acp_thread
5135 thread.update(cx, |thread, cx| {
5136 thread.on_terminal_provider_event(
5137 TerminalProviderEvent::Output {
5138 terminal_id: terminal_id.clone(),
5139 data: b"hello buffered".to_vec(),
5140 },
5141 cx,
5142 );
5143 });
5144
5145 // Create a display-only terminal and then send Created
5146 let lower = cx.new(|cx| {
5147 let builder = ::terminal::TerminalBuilder::new_display_only(
5148 ::terminal::terminal_settings::CursorShape::default(),
5149 ::terminal::terminal_settings::AlternateScroll::On,
5150 None,
5151 0,
5152 cx.background_executor(),
5153 PathStyle::local(),
5154 );
5155 builder.subscribe(cx)
5156 });
5157
5158 thread.update(cx, |thread, cx| {
5159 thread.on_terminal_provider_event(
5160 TerminalProviderEvent::Created {
5161 terminal_id: terminal_id.clone(),
5162 label: "Buffered Test".to_string(),
5163 cwd: None,
5164 output_byte_limit: None,
5165 terminal: lower.clone(),
5166 },
5167 cx,
5168 );
5169 });
5170
5171 // After Created, buffered Output should have been flushed into the renderer
5172 let content = thread.read_with(cx, |thread, cx| {
5173 let term = thread.terminal(terminal_id.clone()).unwrap();
5174 term.read_with(cx, |t, cx| t.inner().read(cx).get_content())
5175 });
5176
5177 assert!(
5178 content.contains("hello buffered"),
5179 "expected buffered output to render, got: {content}"
5180 );
5181 }
5182
5183 #[gpui::test]
5184 async fn test_terminal_exit_preserves_visible_scrollback(cx: &mut gpui::TestAppContext) {
5185 init_test(cx);
5186
5187 let fs = FakeFs::new(cx.executor());
5188 let project = Project::test(fs, [], cx).await;
5189 let connection = Rc::new(FakeAgentConnection::new());
5190 let thread = cx
5191 .update(|cx| {
5192 connection.new_session(
5193 project,
5194 PathList::new(&[std::path::Path::new(path!("/test"))]),
5195 cx,
5196 )
5197 })
5198 .await
5199 .unwrap();
5200
5201 let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
5202 let lower = cx.new(|cx| {
5203 let builder = ::terminal::TerminalBuilder::new_display_only(
5204 ::terminal::terminal_settings::CursorShape::default(),
5205 ::terminal::terminal_settings::AlternateScroll::On,
5206 None,
5207 0,
5208 cx.background_executor(),
5209 PathStyle::local(),
5210 );
5211 builder.subscribe(cx)
5212 });
5213
5214 thread.update(cx, |thread, cx| {
5215 thread.on_terminal_provider_event(
5216 TerminalProviderEvent::Created {
5217 terminal_id: terminal_id.clone(),
5218 label: "Buffered Test".to_string(),
5219 cwd: None,
5220 output_byte_limit: None,
5221 terminal: lower.clone(),
5222 },
5223 cx,
5224 );
5225 });
5226
5227 let mut output = String::new();
5228 for line in 0..15_000 {
5229 output.push_str(&format!("line {line}\n"));
5230 }
5231
5232 thread.update(cx, |thread, cx| {
5233 thread.on_terminal_provider_event(
5234 TerminalProviderEvent::Output {
5235 terminal_id: terminal_id.clone(),
5236 data: output.into_bytes(),
5237 },
5238 cx,
5239 );
5240 thread.on_terminal_provider_event(
5241 TerminalProviderEvent::Exit {
5242 terminal_id: terminal_id.clone(),
5243 status: acp::TerminalExitStatus::new().exit_code(0),
5244 },
5245 cx,
5246 );
5247 });
5248
5249 let content = thread.read_with(cx, |thread, cx| {
5250 let term = thread.terminal(terminal_id.clone()).unwrap();
5251 term.read_with(cx, |term, cx| term.inner().read(cx).get_content())
5252 });
5253
5254 assert!(
5255 content.contains("line 14999"),
5256 "expected output to remain visible after terminal exit, got: {content}"
5257 );
5258 }
5259
5260 #[gpui::test]
5261 async fn test_terminal_output_and_exit_buffered_before_created(cx: &mut gpui::TestAppContext) {
5262 init_test(cx);
5263
5264 let fs = FakeFs::new(cx.executor());
5265 let project = Project::test(fs, [], cx).await;
5266 let connection = Rc::new(FakeAgentConnection::new());
5267 let thread = cx
5268 .update(|cx| {
5269 connection.new_session(
5270 project,
5271 PathList::new(&[std::path::Path::new(path!("/test"))]),
5272 cx,
5273 )
5274 })
5275 .await
5276 .unwrap();
5277
5278 let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
5279
5280 // Send Output BEFORE Created
5281 thread.update(cx, |thread, cx| {
5282 thread.on_terminal_provider_event(
5283 TerminalProviderEvent::Output {
5284 terminal_id: terminal_id.clone(),
5285 data: b"pre-exit data".to_vec(),
5286 },
5287 cx,
5288 );
5289 });
5290
5291 // Send Exit BEFORE Created
5292 thread.update(cx, |thread, cx| {
5293 thread.on_terminal_provider_event(
5294 TerminalProviderEvent::Exit {
5295 terminal_id: terminal_id.clone(),
5296 status: acp::TerminalExitStatus::new().exit_code(0),
5297 },
5298 cx,
5299 );
5300 });
5301
5302 // Now create a display-only lower-level terminal and send Created
5303 let lower = cx.new(|cx| {
5304 let builder = ::terminal::TerminalBuilder::new_display_only(
5305 ::terminal::terminal_settings::CursorShape::default(),
5306 ::terminal::terminal_settings::AlternateScroll::On,
5307 None,
5308 0,
5309 cx.background_executor(),
5310 PathStyle::local(),
5311 );
5312 builder.subscribe(cx)
5313 });
5314
5315 thread.update(cx, |thread, cx| {
5316 thread.on_terminal_provider_event(
5317 TerminalProviderEvent::Created {
5318 terminal_id: terminal_id.clone(),
5319 label: "Buffered Exit Test".to_string(),
5320 cwd: None,
5321 output_byte_limit: None,
5322 terminal: lower.clone(),
5323 },
5324 cx,
5325 );
5326 });
5327
5328 // Output should be present after Created (flushed from buffer)
5329 let content = thread.read_with(cx, |thread, cx| {
5330 let term = thread.terminal(terminal_id.clone()).unwrap();
5331 term.read_with(cx, |t, cx| t.inner().read(cx).get_content())
5332 });
5333
5334 assert!(
5335 content.contains("pre-exit data"),
5336 "expected pre-exit data to render, got: {content}"
5337 );
5338 }
5339
5340 /// Test that killing a terminal via Terminal::kill properly:
5341 /// 1. Causes wait_for_exit to complete (doesn't hang forever)
5342 /// 2. The underlying terminal still has the output that was written before the kill
5343 ///
5344 /// This test verifies that the fix to kill_active_task (which now also kills
5345 /// the shell process in addition to the foreground process) properly allows
5346 /// wait_for_exit to complete instead of hanging indefinitely.
5347 #[cfg(unix)]
5348 #[gpui::test]
5349 async fn test_terminal_kill_allows_wait_for_exit_to_complete(cx: &mut gpui::TestAppContext) {
5350 use std::collections::HashMap;
5351 use task::Shell;
5352 use util::shell_builder::ShellBuilder;
5353
5354 init_test(cx);
5355 cx.executor().allow_parking();
5356
5357 let fs = FakeFs::new(cx.executor());
5358 let project = Project::test(fs, [], cx).await;
5359 let connection = Rc::new(FakeAgentConnection::new());
5360 let thread = cx
5361 .update(|cx| {
5362 connection.new_session(
5363 project.clone(),
5364 PathList::new(&[Path::new(path!("/test"))]),
5365 cx,
5366 )
5367 })
5368 .await
5369 .unwrap();
5370
5371 let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
5372
5373 // Create a real PTY terminal that runs a command which prints output then sleeps
5374 // We use printf instead of echo and chain with && sleep to ensure proper execution
5375 let (completion_tx, _completion_rx) = async_channel::unbounded();
5376 let (program, args) = ShellBuilder::new(&Shell::System, false).build(
5377 Some("printf 'output_before_kill\\n' && sleep 60".to_owned()),
5378 &[],
5379 );
5380
5381 let builder = cx
5382 .update(|cx| {
5383 ::terminal::TerminalBuilder::new(
5384 None,
5385 None,
5386 task::Shell::WithArguments {
5387 program,
5388 args,
5389 title_override: None,
5390 },
5391 HashMap::default(),
5392 ::terminal::terminal_settings::CursorShape::default(),
5393 ::terminal::terminal_settings::AlternateScroll::On,
5394 None,
5395 vec![],
5396 0,
5397 false,
5398 0,
5399 Some(completion_tx),
5400 cx,
5401 vec![],
5402 PathStyle::local(),
5403 )
5404 })
5405 .await
5406 .unwrap();
5407
5408 let lower_terminal = cx.new(|cx| builder.subscribe(cx));
5409
5410 // Create the acp_thread Terminal wrapper
5411 thread.update(cx, |thread, cx| {
5412 thread.on_terminal_provider_event(
5413 TerminalProviderEvent::Created {
5414 terminal_id: terminal_id.clone(),
5415 label: "printf output_before_kill && sleep 60".to_string(),
5416 cwd: None,
5417 output_byte_limit: None,
5418 terminal: lower_terminal.clone(),
5419 },
5420 cx,
5421 );
5422 });
5423
5424 // Poll until the printf command produces output, rather than using a
5425 // fixed sleep which is flaky on loaded machines.
5426 let deadline = std::time::Instant::now() + Duration::from_secs(10);
5427 loop {
5428 let has_output = thread.read_with(cx, |thread, cx| {
5429 let term = thread
5430 .terminals
5431 .get(&terminal_id)
5432 .expect("terminal not found");
5433 let content = term.read(cx).inner().read(cx).get_content();
5434 content.contains("output_before_kill")
5435 });
5436 if has_output {
5437 break;
5438 }
5439 assert!(
5440 std::time::Instant::now() < deadline,
5441 "Timed out waiting for printf output to appear in terminal",
5442 );
5443 cx.executor().timer(Duration::from_millis(50)).await;
5444 }
5445
5446 // Get the acp_thread Terminal and kill it
5447 let wait_for_exit = thread.update(cx, |thread, cx| {
5448 let term = thread.terminals.get(&terminal_id).unwrap();
5449 let wait_for_exit = term.read(cx).wait_for_exit();
5450 term.update(cx, |term, cx| {
5451 term.kill(cx);
5452 });
5453 wait_for_exit
5454 });
5455
5456 // KEY ASSERTION: wait_for_exit should complete within a reasonable time (not hang).
5457 // Before the fix to kill_active_task, this would hang forever because
5458 // only the foreground process was killed, not the shell, so the PTY
5459 // child never exited and wait_for_completed_task never completed.
5460 let exit_result = futures::select! {
5461 result = futures::FutureExt::fuse(wait_for_exit) => Some(result),
5462 _ = futures::FutureExt::fuse(cx.background_executor.timer(Duration::from_secs(5))) => None,
5463 };
5464
5465 assert!(
5466 exit_result.is_some(),
5467 "wait_for_exit should complete after kill, but it timed out. \
5468 This indicates kill_active_task is not properly killing the shell process."
5469 );
5470
5471 // Give the system a chance to process any pending updates
5472 cx.run_until_parked();
5473
5474 // Verify that the underlying terminal still has the output that was
5475 // written before the kill. This verifies that killing doesn't lose output.
5476 let inner_content = thread.read_with(cx, |thread, cx| {
5477 let term = thread.terminals.get(&terminal_id).unwrap();
5478 term.read(cx).inner().read(cx).get_content()
5479 });
5480
5481 assert!(
5482 inner_content.contains("output_before_kill"),
5483 "Underlying terminal should contain output from before kill, got: {}",
5484 inner_content
5485 );
5486 }
5487
5488 #[gpui::test]
5489 async fn test_push_user_content_block(cx: &mut gpui::TestAppContext) {
5490 init_test(cx);
5491
5492 let fs = FakeFs::new(cx.executor());
5493 let project = Project::test(fs, [], cx).await;
5494 let connection = Rc::new(FakeAgentConnection::new());
5495 let thread = cx
5496 .update(|cx| {
5497 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5498 })
5499 .await
5500 .unwrap();
5501
5502 // Test creating a new user message
5503 thread.update(cx, |thread, cx| {
5504 thread.push_user_content_block(None, "Hello, ".into(), cx);
5505 });
5506
5507 thread.update(cx, |thread, cx| {
5508 assert_eq!(thread.entries.len(), 1);
5509 if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
5510 assert_eq!(user_msg.protocol_id, None);
5511 assert_eq!(user_msg.client_id, None);
5512 assert_eq!(user_msg.content.to_markdown(cx), "Hello, ");
5513 } else {
5514 panic!("Expected UserMessage");
5515 }
5516 });
5517
5518 // Test appending to existing user message
5519 let message_1_id = ClientUserMessageId::new();
5520 thread.update(cx, |thread, cx| {
5521 thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx);
5522 });
5523
5524 thread.update(cx, |thread, cx| {
5525 assert_eq!(thread.entries.len(), 1);
5526 if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
5527 assert_eq!(user_msg.protocol_id, None);
5528 assert_eq!(user_msg.client_id, Some(message_1_id));
5529 assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!");
5530 } else {
5531 panic!("Expected UserMessage");
5532 }
5533 });
5534
5535 // Test creating new user message after assistant message
5536 thread.update(cx, |thread, cx| {
5537 thread.push_assistant_content_block("Assistant response".into(), false, cx);
5538 });
5539
5540 let message_2_id = ClientUserMessageId::new();
5541 thread.update(cx, |thread, cx| {
5542 thread.push_user_content_block(
5543 Some(message_2_id.clone()),
5544 "New user message".into(),
5545 cx,
5546 );
5547 });
5548
5549 thread.update(cx, |thread, cx| {
5550 assert_eq!(thread.entries.len(), 3);
5551 if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] {
5552 assert_eq!(user_msg.protocol_id, None);
5553 assert_eq!(user_msg.client_id, Some(message_2_id));
5554 assert_eq!(user_msg.content.to_markdown(cx), "New user message");
5555 } else {
5556 panic!("Expected UserMessage at index 2");
5557 }
5558 });
5559 }
5560
5561 #[gpui::test]
5562 async fn test_user_message_chunks_use_protocol_message_id_boundaries(
5563 cx: &mut gpui::TestAppContext,
5564 ) {
5565 init_test(cx);
5566
5567 let fs = FakeFs::new(cx.executor());
5568 let project = Project::test(fs, [], cx).await;
5569 let connection = Rc::new(FakeAgentConnection::new());
5570 let thread = cx
5571 .update(|cx| {
5572 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5573 })
5574 .await
5575 .unwrap();
5576
5577 thread.update(cx, |thread, cx| {
5578 thread
5579 .handle_session_update(
5580 acp::SessionUpdate::UserMessageChunk(
5581 acp::ContentChunk::new("First ".into()).message_id("msg_user_1"),
5582 ),
5583 cx,
5584 )
5585 .unwrap();
5586 thread
5587 .handle_session_update(
5588 acp::SessionUpdate::UserMessageChunk(
5589 acp::ContentChunk::new("message".into()).message_id("msg_user_1"),
5590 ),
5591 cx,
5592 )
5593 .unwrap();
5594 thread
5595 .handle_session_update(
5596 acp::SessionUpdate::UserMessageChunk(
5597 acp::ContentChunk::new("Second message".into()).message_id("msg_user_2"),
5598 ),
5599 cx,
5600 )
5601 .unwrap();
5602 thread
5603 .handle_session_update(
5604 acp::SessionUpdate::UserMessageChunk(
5605 acp::ContentChunk::new("Echo".into()).message_id("msg_user_3"),
5606 ),
5607 cx,
5608 )
5609 .unwrap();
5610 thread
5611 .handle_session_update(
5612 acp::SessionUpdate::UserMessageChunk(
5613 acp::ContentChunk::new("Echo".into()).message_id("msg_user_3"),
5614 ),
5615 cx,
5616 )
5617 .unwrap();
5618 });
5619
5620 thread.update(cx, |thread, cx| {
5621 assert_eq!(thread.entries.len(), 3);
5622
5623 let AgentThreadEntry::UserMessage(first_message) = &thread.entries[0] else {
5624 panic!("expected first entry to be a user message")
5625 };
5626 assert_eq!(first_message.content.to_markdown(cx), "First message");
5627 assert_eq!(
5628 first_message
5629 .protocol_id
5630 .as_ref()
5631 .map(ToString::to_string)
5632 .as_deref(),
5633 Some("msg_user_1")
5634 );
5635
5636 let AgentThreadEntry::UserMessage(second_message) = &thread.entries[1] else {
5637 panic!("expected second entry to be a user message")
5638 };
5639 assert_eq!(second_message.content.to_markdown(cx), "Second message");
5640 assert_eq!(
5641 second_message
5642 .protocol_id
5643 .as_ref()
5644 .map(ToString::to_string)
5645 .as_deref(),
5646 Some("msg_user_2")
5647 );
5648
5649 let AgentThreadEntry::UserMessage(third_message) = &thread.entries[2] else {
5650 panic!("expected third entry to be a user message")
5651 };
5652 assert_eq!(third_message.content.to_markdown(cx), "EchoEcho");
5653 assert_eq!(
5654 third_message
5655 .protocol_id
5656 .as_ref()
5657 .map(ToString::to_string)
5658 .as_deref(),
5659 Some("msg_user_3")
5660 );
5661 });
5662 }
5663
5664 #[gpui::test]
5665 async fn test_protocol_user_chunk_does_not_merge_into_optimistic_prompt(
5666 cx: &mut gpui::TestAppContext,
5667 ) {
5668 init_test(cx);
5669
5670 let fs = FakeFs::new(cx.executor());
5671 let project = Project::test(fs, [], cx).await;
5672 let connection = Rc::new(FakeAgentConnection::new());
5673 let thread = cx
5674 .update(|cx| {
5675 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5676 })
5677 .await
5678 .unwrap();
5679
5680 thread.update(cx, |thread, cx| {
5681 thread.push_user_content_block_with_protocol_id(
5682 None,
5683 true,
5684 None,
5685 "Typed prompt".into(),
5686 false,
5687 cx,
5688 );
5689 thread
5690 .handle_session_update(
5691 acp::SessionUpdate::UserMessageChunk(
5692 acp::ContentChunk::new("Agent user chunk".into())
5693 .message_id("agent_user_chunk"),
5694 ),
5695 cx,
5696 )
5697 .unwrap();
5698 });
5699
5700 thread.update(cx, |thread, cx| {
5701 assert_eq!(thread.entries.len(), 2);
5702
5703 let AgentThreadEntry::UserMessage(optimistic_message) = &thread.entries[0] else {
5704 panic!("expected first entry to be optimistic user message")
5705 };
5706 assert!(optimistic_message.is_optimistic);
5707 assert_eq!(optimistic_message.content.to_markdown(cx), "Typed prompt");
5708 assert!(optimistic_message.protocol_id.is_none());
5709 assert!(optimistic_message.client_id.is_none());
5710
5711 let AgentThreadEntry::UserMessage(agent_message) = &thread.entries[1] else {
5712 panic!("expected second entry to be protocol user chunk")
5713 };
5714 assert!(!agent_message.is_optimistic);
5715 assert_eq!(agent_message.content.to_markdown(cx), "Agent user chunk");
5716 assert_eq!(
5717 agent_message
5718 .protocol_id
5719 .as_ref()
5720 .map(ToString::to_string)
5721 .as_deref(),
5722 Some("agent_user_chunk")
5723 );
5724 });
5725 }
5726
5727 #[gpui::test]
5728 async fn test_assistant_chunks_use_protocol_message_id_boundaries(
5729 cx: &mut gpui::TestAppContext,
5730 ) {
5731 init_test(cx);
5732
5733 let fs = FakeFs::new(cx.executor());
5734 let project = Project::test(fs, [], cx).await;
5735 let connection = Rc::new(FakeAgentConnection::new());
5736 let thread = cx
5737 .update(|cx| {
5738 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5739 })
5740 .await
5741 .unwrap();
5742
5743 thread.update(cx, |thread, cx| {
5744 thread
5745 .handle_session_update(
5746 acp::SessionUpdate::AgentThoughtChunk(
5747 acp::ContentChunk::new("Thinking ".into()).message_id("msg_thought_1"),
5748 ),
5749 cx,
5750 )
5751 .unwrap();
5752 thread
5753 .handle_session_update(
5754 acp::SessionUpdate::AgentThoughtChunk(
5755 acp::ContentChunk::new("hard".into()).message_id("msg_thought_1"),
5756 ),
5757 cx,
5758 )
5759 .unwrap();
5760 thread
5761 .handle_session_update(
5762 acp::SessionUpdate::AgentThoughtChunk(
5763 acp::ContentChunk::new("A separate thought".into())
5764 .message_id("msg_thought_2"),
5765 ),
5766 cx,
5767 )
5768 .unwrap();
5769 thread
5770 .handle_session_update(
5771 acp::SessionUpdate::AgentMessageChunk(
5772 acp::ContentChunk::new("Answer ".into()).message_id("msg_agent_1"),
5773 ),
5774 cx,
5775 )
5776 .unwrap();
5777 thread
5778 .handle_session_update(
5779 acp::SessionUpdate::AgentMessageChunk(
5780 acp::ContentChunk::new("done".into()).message_id("msg_agent_1"),
5781 ),
5782 cx,
5783 )
5784 .unwrap();
5785 thread
5786 .handle_session_update(
5787 acp::SessionUpdate::AgentMessageChunk(
5788 acp::ContentChunk::new("Follow-up".into()).message_id("msg_agent_2"),
5789 ),
5790 cx,
5791 )
5792 .unwrap();
5793 });
5794
5795 thread.update(cx, |thread, cx| {
5796 assert_eq!(thread.entries.len(), 1);
5797 let AgentThreadEntry::AssistantMessage(message) = &thread.entries[0] else {
5798 panic!("expected assistant entry")
5799 };
5800 assert_eq!(message.chunks.len(), 4);
5801
5802 let AssistantMessageChunk::Thought { id, block } = &message.chunks[0] else {
5803 panic!("expected first chunk to be a thought")
5804 };
5805 assert_eq!(block.to_markdown(cx), "Thinking hard");
5806 assert_eq!(
5807 id.as_ref().map(ToString::to_string).as_deref(),
5808 Some("msg_thought_1")
5809 );
5810
5811 let AssistantMessageChunk::Thought { id, block } = &message.chunks[1] else {
5812 panic!("expected second chunk to be a thought")
5813 };
5814 assert_eq!(block.to_markdown(cx), "A separate thought");
5815 assert_eq!(
5816 id.as_ref().map(ToString::to_string).as_deref(),
5817 Some("msg_thought_2")
5818 );
5819
5820 let AssistantMessageChunk::Message { id, block } = &message.chunks[2] else {
5821 panic!("expected third chunk to be a message")
5822 };
5823 assert_eq!(block.to_markdown(cx), "Answer done");
5824 assert_eq!(
5825 id.as_ref().map(ToString::to_string).as_deref(),
5826 Some("msg_agent_1")
5827 );
5828
5829 let AssistantMessageChunk::Message { id, block } = &message.chunks[3] else {
5830 panic!("expected fourth chunk to be a message")
5831 };
5832 assert_eq!(block.to_markdown(cx), "Follow-up");
5833 assert_eq!(
5834 id.as_ref().map(ToString::to_string).as_deref(),
5835 Some("msg_agent_2")
5836 );
5837 });
5838 }
5839
5840 #[gpui::test]
5841 async fn test_thinking_concatenation(cx: &mut gpui::TestAppContext) {
5842 init_test(cx);
5843
5844 let fs = FakeFs::new(cx.executor());
5845 let project = Project::test(fs, [], cx).await;
5846 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
5847 |_, thread, mut cx| {
5848 async move {
5849 thread.update(&mut cx, |thread, cx| {
5850 thread
5851 .handle_session_update(
5852 acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
5853 "Thinking ".into(),
5854 )),
5855 cx,
5856 )
5857 .unwrap();
5858 thread
5859 .handle_session_update(
5860 acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
5861 "hard!".into(),
5862 )),
5863 cx,
5864 )
5865 .unwrap();
5866 })?;
5867 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
5868 }
5869 .boxed_local()
5870 },
5871 ));
5872
5873 let thread = cx
5874 .update(|cx| {
5875 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5876 })
5877 .await
5878 .unwrap();
5879
5880 thread
5881 .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
5882 .await
5883 .unwrap();
5884
5885 let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
5886 assert_eq!(
5887 output,
5888 indoc! {r#"
5889 ## User
5890
5891 Hello from Zed!
5892
5893 ## Assistant
5894
5895 <thinking>
5896 Thinking hard!
5897 </thinking>
5898
5899 "#}
5900 );
5901 }
5902
5903 /// `send_command` runs the turn (the connection receives the typed command)
5904 /// but never echoes a user-message bubble, so commands like `/compact` don't
5905 /// show a fake user message implying the text was sent to the model.
5906 #[gpui::test]
5907 async fn test_send_command_does_not_echo_user_message(cx: &mut gpui::TestAppContext) {
5908 init_test(cx);
5909
5910 let fs = FakeFs::new(cx.executor());
5911 let project = Project::test(fs, [], cx).await;
5912
5913 let received_prompt: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
5914 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
5915 let received_prompt = received_prompt.clone();
5916 move |request, thread, mut cx| {
5917 let received_prompt = received_prompt.clone();
5918 async move {
5919 if let Some(acp::ContentBlock::Text(text)) = request.prompt.first() {
5920 *received_prompt.borrow_mut() = Some(text.text.clone());
5921 }
5922 // Simulate a native command producing its own thread entry
5923 // (here a compaction) rather than echoing a user message.
5924 thread.update(&mut cx, |thread, cx| {
5925 thread.push_context_compaction(
5926 ContextCompaction {
5927 id: ContextCompactionId("c1".into()),
5928 status: ContextCompactionStatus::Completed,
5929 summary: None,
5930 },
5931 cx,
5932 );
5933 })?;
5934 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
5935 }
5936 .boxed_local()
5937 }
5938 }));
5939
5940 let thread = cx
5941 .update(|cx| {
5942 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
5943 })
5944 .await
5945 .unwrap();
5946
5947 cx.update(|cx| {
5948 thread.update(cx, |thread, cx| {
5949 thread.send_command(vec!["/compact".into()], cx)
5950 })
5951 })
5952 .await
5953 .unwrap();
5954
5955 // The command turn ran: the connection received the typed command.
5956 assert_eq!(received_prompt.borrow().as_deref(), Some("/compact"));
5957
5958 thread.update(cx, |thread, _cx| {
5959 assert!(
5960 !thread
5961 .entries
5962 .iter()
5963 .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))),
5964 "send_command must not echo a user message"
5965 );
5966 // The command's own entry (here a compaction) is still shown.
5967 assert!(
5968 thread
5969 .entries
5970 .iter()
5971 .any(|entry| matches!(entry, AgentThreadEntry::ContextCompaction(_))),
5972 "the command's own thread entry should still be present"
5973 );
5974 });
5975 }
5976
5977 #[gpui::test]
5978 async fn test_ignore_echoed_user_message_chunks_during_active_turn(
5979 cx: &mut gpui::TestAppContext,
5980 ) {
5981 init_test(cx);
5982
5983 let fs = FakeFs::new(cx.executor());
5984 let project = Project::test(fs, [], cx).await;
5985 let connection = Rc::new(
5986 FakeAgentConnection::new()
5987 .without_truncate_support()
5988 .on_user_message(|request, thread, mut cx| {
5989 async move {
5990 let prompt = request.prompt.first().cloned().unwrap_or_else(|| "".into());
5991
5992 thread.update(&mut cx, |thread, cx| {
5993 thread
5994 .handle_session_update(
5995 acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(
5996 prompt,
5997 )),
5998 cx,
5999 )
6000 .unwrap();
6001 })?;
6002
6003 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
6004 }
6005 .boxed_local()
6006 }),
6007 );
6008
6009 let thread = cx
6010 .update(|cx| {
6011 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6012 })
6013 .await
6014 .unwrap();
6015
6016 thread
6017 .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
6018 .await
6019 .unwrap();
6020
6021 let output = thread.read_with(cx, |thread, cx| thread.to_markdown(cx));
6022 assert_eq!(output.matches("Hello from Zed!").count(), 1);
6023 thread.read_with(cx, |thread, _cx| {
6024 let Some(AgentThreadEntry::UserMessage(message)) = thread.entries.first() else {
6025 panic!("expected optimistic user message");
6026 };
6027 assert_eq!(message.protocol_id, None);
6028 assert_eq!(message.client_id, None);
6029 assert!(message.is_optimistic);
6030 });
6031 }
6032
6033 #[gpui::test]
6034 async fn test_edits_concurrently_to_user(cx: &mut TestAppContext) {
6035 init_test(cx);
6036
6037 let fs = FakeFs::new(cx.executor());
6038 fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\n"}))
6039 .await;
6040 let project = Project::test(fs.clone(), [], cx).await;
6041 let (read_file_tx, read_file_rx) = oneshot::channel::<()>();
6042 let read_file_tx = Rc::new(RefCell::new(Some(read_file_tx)));
6043 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
6044 move |_, thread, mut cx| {
6045 let read_file_tx = read_file_tx.clone();
6046 async move {
6047 let content = thread
6048 .update(&mut cx, |thread, cx| {
6049 thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
6050 })
6051 .unwrap()
6052 .await
6053 .unwrap();
6054 assert_eq!(content, "one\ntwo\nthree\n");
6055 read_file_tx.take().unwrap().send(()).unwrap();
6056 thread
6057 .update(&mut cx, |thread, cx| {
6058 thread.write_text_file(
6059 path!("/tmp/foo").into(),
6060 "one\ntwo\nthree\nfour\nfive\n".to_string(),
6061 cx,
6062 )
6063 })
6064 .unwrap()
6065 .await
6066 .unwrap();
6067 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
6068 }
6069 .boxed_local()
6070 },
6071 ));
6072
6073 let (worktree, pathbuf) = project
6074 .update(cx, |project, cx| {
6075 project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
6076 })
6077 .await
6078 .unwrap();
6079 let buffer = project
6080 .update(cx, |project, cx| {
6081 project.open_buffer((worktree.read(cx).id(), pathbuf), cx)
6082 })
6083 .await
6084 .unwrap();
6085
6086 let thread = cx
6087 .update(|cx| {
6088 connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
6089 })
6090 .await
6091 .unwrap();
6092
6093 let request = thread.update(cx, |thread, cx| {
6094 thread.send_raw("Extend the count in /tmp/foo", cx)
6095 });
6096 read_file_rx.await.ok();
6097 buffer.update(cx, |buffer, cx| {
6098 buffer.edit([(0..0, "zero\n".to_string())], None, cx);
6099 });
6100 cx.run_until_parked();
6101 assert_eq!(
6102 buffer.read_with(cx, |buffer, _| buffer.text()),
6103 "zero\none\ntwo\nthree\nfour\nfive\n"
6104 );
6105 assert_eq!(
6106 String::from_utf8(fs.read_file_sync(path!("/tmp/foo")).unwrap()).unwrap(),
6107 "zero\none\ntwo\nthree\nfour\nfive\n"
6108 );
6109 request.await.unwrap();
6110 }
6111
6112 #[gpui::test]
6113 async fn test_reading_from_line(cx: &mut TestAppContext) {
6114 init_test(cx);
6115
6116 let fs = FakeFs::new(cx.executor());
6117 fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\nfour\n"}))
6118 .await;
6119 let project = Project::test(fs.clone(), [], cx).await;
6120 project
6121 .update(cx, |project, cx| {
6122 project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
6123 })
6124 .await
6125 .unwrap();
6126
6127 let connection = Rc::new(FakeAgentConnection::new());
6128
6129 let thread = cx
6130 .update(|cx| {
6131 connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
6132 })
6133 .await
6134 .unwrap();
6135
6136 // Whole file
6137 let content = thread
6138 .update(cx, |thread, cx| {
6139 thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
6140 })
6141 .await
6142 .unwrap();
6143
6144 assert_eq!(content, "one\ntwo\nthree\nfour\n");
6145
6146 // Only start line
6147 let content = thread
6148 .update(cx, |thread, cx| {
6149 thread.read_text_file(path!("/tmp/foo").into(), Some(3), None, false, cx)
6150 })
6151 .await
6152 .unwrap();
6153
6154 assert_eq!(content, "three\nfour\n");
6155
6156 // Only limit
6157 let content = thread
6158 .update(cx, |thread, cx| {
6159 thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx)
6160 })
6161 .await
6162 .unwrap();
6163
6164 assert_eq!(content, "one\ntwo\n");
6165
6166 // Range
6167 let content = thread
6168 .update(cx, |thread, cx| {
6169 thread.read_text_file(path!("/tmp/foo").into(), Some(2), Some(2), false, cx)
6170 })
6171 .await
6172 .unwrap();
6173
6174 assert_eq!(content, "two\nthree\n");
6175
6176 // Invalid
6177 let err = thread
6178 .update(cx, |thread, cx| {
6179 thread.read_text_file(path!("/tmp/foo").into(), Some(6), Some(2), false, cx)
6180 })
6181 .await
6182 .unwrap_err();
6183
6184 assert_eq!(
6185 err.to_string(),
6186 "Invalid params: \"Attempting to read beyond the end of the file, line 5:0\""
6187 );
6188 }
6189
6190 #[gpui::test]
6191 async fn test_reading_empty_file(cx: &mut TestAppContext) {
6192 init_test(cx);
6193
6194 let fs = FakeFs::new(cx.executor());
6195 fs.insert_tree(path!("/tmp"), json!({"foo": ""})).await;
6196 let project = Project::test(fs.clone(), [], cx).await;
6197 project
6198 .update(cx, |project, cx| {
6199 project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
6200 })
6201 .await
6202 .unwrap();
6203
6204 let connection = Rc::new(FakeAgentConnection::new());
6205
6206 let thread = cx
6207 .update(|cx| {
6208 connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
6209 })
6210 .await
6211 .unwrap();
6212
6213 // Whole file
6214 let content = thread
6215 .update(cx, |thread, cx| {
6216 thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
6217 })
6218 .await
6219 .unwrap();
6220
6221 assert_eq!(content, "");
6222
6223 // Only start line
6224 let content = thread
6225 .update(cx, |thread, cx| {
6226 thread.read_text_file(path!("/tmp/foo").into(), Some(1), None, false, cx)
6227 })
6228 .await
6229 .unwrap();
6230
6231 assert_eq!(content, "");
6232
6233 // Only limit
6234 let content = thread
6235 .update(cx, |thread, cx| {
6236 thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx)
6237 })
6238 .await
6239 .unwrap();
6240
6241 assert_eq!(content, "");
6242
6243 // Range
6244 let content = thread
6245 .update(cx, |thread, cx| {
6246 thread.read_text_file(path!("/tmp/foo").into(), Some(1), Some(1), false, cx)
6247 })
6248 .await
6249 .unwrap();
6250
6251 assert_eq!(content, "");
6252
6253 // Invalid
6254 let err = thread
6255 .update(cx, |thread, cx| {
6256 thread.read_text_file(path!("/tmp/foo").into(), Some(5), Some(2), false, cx)
6257 })
6258 .await
6259 .unwrap_err();
6260
6261 assert_eq!(
6262 err.to_string(),
6263 "Invalid params: \"Attempting to read beyond the end of the file, line 1:0\""
6264 );
6265 }
6266 #[gpui::test]
6267 async fn test_reading_non_existing_file(cx: &mut TestAppContext) {
6268 init_test(cx);
6269
6270 let fs = FakeFs::new(cx.executor());
6271 fs.insert_tree(path!("/tmp"), json!({})).await;
6272 let project = Project::test(fs.clone(), [], cx).await;
6273 project
6274 .update(cx, |project, cx| {
6275 project.find_or_create_worktree(path!("/tmp"), true, cx)
6276 })
6277 .await
6278 .unwrap();
6279
6280 let connection = Rc::new(FakeAgentConnection::new());
6281
6282 let thread = cx
6283 .update(|cx| {
6284 connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx)
6285 })
6286 .await
6287 .unwrap();
6288
6289 // Out of project file
6290 let err = thread
6291 .update(cx, |thread, cx| {
6292 thread.read_text_file(path!("/foo").into(), None, None, false, cx)
6293 })
6294 .await
6295 .unwrap_err();
6296
6297 assert_eq!(err.code, acp::ErrorCode::ResourceNotFound);
6298 }
6299
6300 #[gpui::test]
6301 async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
6302 init_test(cx);
6303
6304 let fs = FakeFs::new(cx.executor());
6305 let project = Project::test(fs, [], cx).await;
6306 let id = acp::ToolCallId::new("test");
6307
6308 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
6309 let id = id.clone();
6310 move |_, thread, mut cx| {
6311 let id = id.clone();
6312 async move {
6313 thread
6314 .update(&mut cx, |thread, cx| {
6315 thread.handle_session_update(
6316 acp::SessionUpdate::ToolCall(
6317 acp::ToolCall::new(id.clone(), "Label")
6318 .kind(acp::ToolKind::Fetch)
6319 .status(acp::ToolCallStatus::InProgress),
6320 ),
6321 cx,
6322 )
6323 })
6324 .unwrap()
6325 .unwrap();
6326 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
6327 }
6328 .boxed_local()
6329 }
6330 }));
6331
6332 let thread = cx
6333 .update(|cx| {
6334 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6335 })
6336 .await
6337 .unwrap();
6338
6339 let request = thread.update(cx, |thread, cx| {
6340 thread.send_raw("Fetch https://example.com", cx)
6341 });
6342
6343 run_until_first_tool_call(&thread, cx).await;
6344
6345 thread.read_with(cx, |thread, _| {
6346 assert!(matches!(
6347 thread.entries[1],
6348 AgentThreadEntry::ToolCall(ToolCall {
6349 status: ToolCallStatus::InProgress,
6350 ..
6351 })
6352 ));
6353 });
6354
6355 thread.update(cx, |thread, cx| thread.cancel(cx)).await;
6356
6357 thread.read_with(cx, |thread, _| {
6358 assert!(matches!(
6359 &thread.entries[1],
6360 AgentThreadEntry::ToolCall(ToolCall {
6361 status: ToolCallStatus::Canceled,
6362 ..
6363 })
6364 ));
6365 });
6366
6367 thread
6368 .update(cx, |thread, cx| {
6369 thread.handle_session_update(
6370 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
6371 id,
6372 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
6373 )),
6374 cx,
6375 )
6376 })
6377 .unwrap();
6378
6379 request.await.unwrap();
6380
6381 thread.read_with(cx, |thread, _| {
6382 assert!(matches!(
6383 thread.entries[1],
6384 AgentThreadEntry::ToolCall(ToolCall {
6385 status: ToolCallStatus::Completed,
6386 ..
6387 })
6388 ));
6389 });
6390 }
6391
6392 #[gpui::test]
6393 async fn test_tool_call_location_resolves_external_file(cx: &mut TestAppContext) {
6394 init_test(cx);
6395
6396 let fs = FakeFs::new(cx.executor());
6397 fs.insert_tree(
6398 path!("/tmp/skills/test-skill"),
6399 json!({ "SKILL.md": "skill body" }),
6400 )
6401 .await;
6402 let project = Project::test(fs, [], cx).await;
6403 let connection = Rc::new(FakeAgentConnection::new());
6404 let thread = cx
6405 .update(|cx| {
6406 connection.new_session(project, PathList::new(&[Path::new(path!("/project"))]), cx)
6407 })
6408 .await
6409 .unwrap();
6410
6411 let skill_path = std::path::PathBuf::from(path!("/tmp/skills/test-skill/SKILL.md"));
6412 thread
6413 .update(cx, |thread, cx| {
6414 thread.handle_session_update(
6415 acp::SessionUpdate::ToolCall(
6416 acp::ToolCall::new("write_file", "Write SKILL.md")
6417 .kind(acp::ToolKind::Edit)
6418 .status(acp::ToolCallStatus::Completed)
6419 .locations(vec![acp::ToolCallLocation::new(skill_path.clone())]),
6420 ),
6421 cx,
6422 )
6423 })
6424 .unwrap();
6425
6426 cx.run_until_parked();
6427
6428 thread.read_with(cx, |thread, cx| {
6429 let (tool_call_location, agent_location) = thread.entries[0]
6430 .location(0)
6431 .expect("external tool-call location should resolve");
6432 assert_eq!(tool_call_location.path, skill_path);
6433
6434 let buffer = agent_location
6435 .buffer
6436 .upgrade()
6437 .expect("resolved location should keep an open buffer");
6438 assert_eq!(buffer.read(cx).text(), "skill body");
6439 });
6440 }
6441
6442 #[gpui::test]
6443 async fn test_duplicate_tool_call_update_preserves_open_permission_request_until_authorized(
6444 cx: &mut TestAppContext,
6445 ) {
6446 init_test(cx);
6447
6448 let fs = FakeFs::new(cx.executor());
6449 let project = Project::test(fs, [], cx).await;
6450 let connection = Rc::new(FakeAgentConnection::new());
6451 let thread = cx
6452 .update(|cx| {
6453 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6454 })
6455 .await
6456 .unwrap();
6457
6458 let tool_call_id = acp::ToolCallId::new("toolu_01duplicate");
6459 let allow_option_id = acp::PermissionOptionId::new("allow");
6460 let permission_task = thread
6461 .update(cx, |thread, cx| {
6462 thread.request_tool_call_authorization(
6463 acp::ToolCall::new(tool_call_id.clone(), "Original title")
6464 .kind(acp::ToolKind::Execute)
6465 .status(acp::ToolCallStatus::Pending)
6466 .content(vec!["original content".into()])
6467 .into(),
6468 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6469 allow_option_id.clone(),
6470 "Allow",
6471 acp::PermissionOptionKind::AllowOnce,
6472 )]),
6473 AuthorizationKind::PermissionGrant,
6474 cx,
6475 )
6476 })
6477 .unwrap();
6478
6479 thread
6480 .update(cx, |thread, cx| {
6481 thread.handle_session_update(
6482 acp::SessionUpdate::ToolCall(
6483 acp::ToolCall::new(tool_call_id.clone(), "Updated title")
6484 .kind(acp::ToolKind::Execute)
6485 .status(acp::ToolCallStatus::Pending)
6486 .content(vec!["updated content".into()]),
6487 ),
6488 cx,
6489 )
6490 })
6491 .unwrap();
6492
6493 thread.read_with(cx, |thread, cx| {
6494 let (_, tool_call) = thread
6495 .tool_call(&tool_call_id)
6496 .expect("tool call should exist");
6497 assert_eq!(tool_call.label.read(cx).source(), "Updated title");
6498 assert!(matches!(
6499 tool_call.status,
6500 ToolCallStatus::WaitingForConfirmation { .. }
6501 ));
6502 assert_eq!(tool_call.content.len(), 1);
6503 assert_eq!(tool_call.content[0].to_markdown(cx), "updated content");
6504 });
6505
6506 thread
6507 .update(cx, |thread, cx| {
6508 thread.handle_session_update(
6509 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
6510 tool_call_id.clone(),
6511 acp::ToolCallUpdateFields::new()
6512 .status(acp::ToolCallStatus::InProgress)
6513 .title("Updated again")
6514 .content(vec!["updated again".into()]),
6515 )),
6516 cx,
6517 )
6518 })
6519 .unwrap();
6520
6521 thread.read_with(cx, |thread, cx| {
6522 let (_, tool_call) = thread
6523 .tool_call(&tool_call_id)
6524 .expect("tool call should exist");
6525 assert_eq!(tool_call.label.read(cx).source(), "Updated again");
6526 assert!(matches!(
6527 tool_call.status,
6528 ToolCallStatus::WaitingForConfirmation { .. }
6529 ));
6530 assert_eq!(tool_call.content.len(), 1);
6531 assert_eq!(tool_call.content[0].to_markdown(cx), "updated again");
6532 });
6533
6534 let selected_outcome = SelectedPermissionOutcome::new(
6535 allow_option_id.clone(),
6536 acp::PermissionOptionKind::AllowOnce,
6537 );
6538 thread.update(cx, |thread, cx| {
6539 thread.authorize_tool_call(tool_call_id.clone(), selected_outcome, cx);
6540 });
6541
6542 thread.read_with(cx, |thread, _cx| {
6543 let (_, tool_call) = thread
6544 .tool_call(&tool_call_id)
6545 .expect("tool call should exist");
6546 assert!(matches!(tool_call.status, ToolCallStatus::InProgress));
6547 });
6548
6549 match permission_task.await {
6550 RequestPermissionOutcome::Selected(outcome) => {
6551 assert_eq!(outcome.option_id, allow_option_id);
6552 assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6553 }
6554 RequestPermissionOutcome::Cancelled => {
6555 panic!("permission request should remain open after duplicate tool call update")
6556 }
6557 }
6558
6559 thread
6560 .update(cx, |thread, cx| {
6561 thread.handle_session_update(
6562 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
6563 tool_call_id.clone(),
6564 acp::ToolCallUpdateFields::new()
6565 .status(acp::ToolCallStatus::Completed)
6566 .title("Completed")
6567 .content(vec!["done".into()]),
6568 )),
6569 cx,
6570 )
6571 })
6572 .unwrap();
6573
6574 thread.read_with(cx, |thread, cx| {
6575 let (_, tool_call) = thread
6576 .tool_call(&tool_call_id)
6577 .expect("tool call should exist");
6578 assert_eq!(tool_call.label.read(cx).source(), "Completed");
6579 assert!(matches!(tool_call.status, ToolCallStatus::Completed));
6580 assert_eq!(tool_call.content.len(), 1);
6581 assert_eq!(tool_call.content[0].to_markdown(cx), "done");
6582 });
6583 }
6584
6585 #[gpui::test]
6586 async fn test_permission_request_tracks_agent_status_until_resolved(cx: &mut TestAppContext) {
6587 init_test(cx);
6588
6589 let fs = FakeFs::new(cx.executor());
6590 let project = Project::test(fs, [], cx).await;
6591 let connection = Rc::new(FakeAgentConnection::new());
6592 let thread = cx
6593 .update(|cx| {
6594 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6595 })
6596 .await
6597 .unwrap();
6598
6599 let tool_call_id = acp::ToolCallId::new("toolu_01auto_resolve");
6600 let permission_task = thread
6601 .update(cx, |thread, cx| {
6602 thread.request_tool_call_authorization(
6603 acp::ToolCall::new(tool_call_id.clone(), "Original title")
6604 .kind(acp::ToolKind::Execute)
6605 .status(acp::ToolCallStatus::Pending)
6606 .into(),
6607 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6608 acp::PermissionOptionId::new("allow"),
6609 "Allow",
6610 acp::PermissionOptionKind::AllowOnce,
6611 )]),
6612 AuthorizationKind::PermissionGrant,
6613 cx,
6614 )
6615 })
6616 .unwrap();
6617
6618 thread
6619 .update(cx, |thread, cx| {
6620 thread.handle_session_update(
6621 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
6622 tool_call_id.clone(),
6623 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress),
6624 )),
6625 cx,
6626 )
6627 })
6628 .unwrap();
6629
6630 thread.read_with(cx, |thread, _cx| {
6631 let (_, tool_call) = thread
6632 .tool_call(&tool_call_id)
6633 .expect("tool call should exist");
6634 assert!(matches!(
6635 tool_call.status,
6636 ToolCallStatus::WaitingForConfirmation {
6637 current_status: acp::ToolCallStatus::InProgress,
6638 ..
6639 }
6640 ));
6641 });
6642
6643 thread.update(cx, |thread, cx| {
6644 thread.authorize_tool_call(
6645 tool_call_id.clone(),
6646 SelectedPermissionOutcome::new(
6647 acp::PermissionOptionId::new("allow"),
6648 acp::PermissionOptionKind::AllowOnce,
6649 ),
6650 cx,
6651 );
6652 });
6653
6654 thread.read_with(cx, |thread, _cx| {
6655 let (_, tool_call) = thread
6656 .tool_call(&tool_call_id)
6657 .expect("tool call should exist");
6658 assert!(matches!(tool_call.status, ToolCallStatus::InProgress));
6659 });
6660
6661 match permission_task.await {
6662 RequestPermissionOutcome::Selected(outcome) => {
6663 assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow"));
6664 assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6665 }
6666 RequestPermissionOutcome::Cancelled => {
6667 panic!("resolved permission request should select an outcome")
6668 }
6669 }
6670 }
6671
6672 #[gpui::test]
6673 async fn test_permission_request_sets_waiting_status_on_existing_tool_call(
6674 cx: &mut TestAppContext,
6675 ) {
6676 init_test(cx);
6677
6678 let fs = FakeFs::new(cx.executor());
6679 let project = Project::test(fs, [], cx).await;
6680 let connection = Rc::new(FakeAgentConnection::new());
6681 let thread = cx
6682 .update(|cx| {
6683 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6684 })
6685 .await
6686 .unwrap();
6687
6688 let tool_call_id = acp::ToolCallId::new("toolu_01existing_permission");
6689 thread
6690 .update(cx, |thread, cx| {
6691 thread.handle_session_update(
6692 acp::SessionUpdate::ToolCall(
6693 acp::ToolCall::new(tool_call_id.clone(), "Running title")
6694 .kind(acp::ToolKind::Execute)
6695 .status(acp::ToolCallStatus::InProgress),
6696 ),
6697 cx,
6698 )
6699 })
6700 .unwrap();
6701
6702 let permission_task = thread
6703 .update(cx, |thread, cx| {
6704 thread.request_tool_call_authorization(
6705 acp::ToolCall::new(tool_call_id.clone(), "Needs permission")
6706 .kind(acp::ToolKind::Execute)
6707 .status(acp::ToolCallStatus::Pending)
6708 .into(),
6709 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6710 acp::PermissionOptionId::new("allow"),
6711 "Allow",
6712 acp::PermissionOptionKind::AllowOnce,
6713 )]),
6714 AuthorizationKind::PermissionGrant,
6715 cx,
6716 )
6717 })
6718 .unwrap();
6719
6720 thread.read_with(cx, |thread, cx| {
6721 let (_, tool_call) = thread
6722 .tool_call(&tool_call_id)
6723 .expect("tool call should exist");
6724 assert_eq!(tool_call.label.read(cx).source(), "Needs permission");
6725 assert!(matches!(
6726 tool_call.status,
6727 ToolCallStatus::WaitingForConfirmation {
6728 current_status: acp::ToolCallStatus::InProgress,
6729 ..
6730 }
6731 ));
6732 });
6733
6734 thread.update(cx, |thread, cx| {
6735 thread.authorize_tool_call(
6736 tool_call_id.clone(),
6737 SelectedPermissionOutcome::new(
6738 acp::PermissionOptionId::new("allow"),
6739 acp::PermissionOptionKind::AllowOnce,
6740 ),
6741 cx,
6742 );
6743 });
6744
6745 match permission_task.await {
6746 RequestPermissionOutcome::Selected(outcome) => {
6747 assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow"));
6748 assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce);
6749 }
6750 RequestPermissionOutcome::Cancelled => {
6751 panic!("permission request should resolve after authorization")
6752 }
6753 }
6754 }
6755
6756 #[gpui::test]
6757 async fn test_cancel_tool_call_authorization_resolves_permission_request(
6758 cx: &mut TestAppContext,
6759 ) {
6760 init_test(cx);
6761
6762 let fs = FakeFs::new(cx.executor());
6763 let project = Project::test(fs, [], cx).await;
6764 let connection = Rc::new(FakeAgentConnection::new());
6765 let thread = cx
6766 .update(|cx| {
6767 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6768 })
6769 .await
6770 .unwrap();
6771
6772 let tool_call_id = acp::ToolCallId::new("toolu_01cancelled_permission");
6773 let permission_task = thread
6774 .update(cx, |thread, cx| {
6775 thread.request_tool_call_authorization(
6776 acp::ToolCall::new(tool_call_id.clone(), "Needs permission")
6777 .kind(acp::ToolKind::Execute)
6778 .status(acp::ToolCallStatus::Pending)
6779 .into(),
6780 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6781 acp::PermissionOptionId::new("allow"),
6782 "Allow",
6783 acp::PermissionOptionKind::AllowOnce,
6784 )]),
6785 AuthorizationKind::PermissionGrant,
6786 cx,
6787 )
6788 })
6789 .unwrap();
6790
6791 thread.update(cx, |thread, cx| {
6792 thread.cancel_tool_call_authorization(&tool_call_id, cx);
6793 });
6794
6795 thread.read_with(cx, |thread, _cx| {
6796 let (_, tool_call) = thread
6797 .tool_call(&tool_call_id)
6798 .expect("tool call should exist");
6799 assert!(matches!(tool_call.status, ToolCallStatus::Canceled));
6800 });
6801
6802 match permission_task.await {
6803 RequestPermissionOutcome::Cancelled => {}
6804 RequestPermissionOutcome::Selected(_) => {
6805 panic!("cancelled permission request should not select an outcome")
6806 }
6807 }
6808 }
6809
6810 #[gpui::test]
6811 async fn test_terminal_tool_call_update_closes_open_permission_request(
6812 cx: &mut TestAppContext,
6813 ) {
6814 init_test(cx);
6815
6816 let fs = FakeFs::new(cx.executor());
6817 let project = Project::test(fs, [], cx).await;
6818 let connection = Rc::new(FakeAgentConnection::new());
6819 let thread = cx
6820 .update(|cx| {
6821 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6822 })
6823 .await
6824 .unwrap();
6825
6826 let tool_call_id = acp::ToolCallId::new("toolu_01completed_while_waiting");
6827 let permission_task = thread
6828 .update(cx, |thread, cx| {
6829 thread.request_tool_call_authorization(
6830 acp::ToolCall::new(tool_call_id.clone(), "Needs permission")
6831 .kind(acp::ToolKind::Execute)
6832 .status(acp::ToolCallStatus::Pending)
6833 .into(),
6834 PermissionOptions::Flat(vec![acp::PermissionOption::new(
6835 acp::PermissionOptionId::new("allow"),
6836 "Allow",
6837 acp::PermissionOptionKind::AllowOnce,
6838 )]),
6839 AuthorizationKind::PermissionGrant,
6840 cx,
6841 )
6842 })
6843 .unwrap();
6844
6845 thread
6846 .update(cx, |thread, cx| {
6847 thread.handle_session_update(
6848 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
6849 tool_call_id.clone(),
6850 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
6851 )),
6852 cx,
6853 )
6854 })
6855 .unwrap();
6856
6857 thread.read_with(cx, |thread, _cx| {
6858 let (_, tool_call) = thread
6859 .tool_call(&tool_call_id)
6860 .expect("tool call should exist");
6861 assert!(matches!(tool_call.status, ToolCallStatus::Completed));
6862 });
6863
6864 match permission_task.await {
6865 RequestPermissionOutcome::Cancelled => {}
6866 RequestPermissionOutcome::Selected(_) => {
6867 panic!("terminal tool call update should close pending permission request")
6868 }
6869 }
6870 }
6871
6872 #[gpui::test]
6873 async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) {
6874 init_test(cx);
6875 let fs = FakeFs::new(cx.background_executor.clone());
6876 fs.insert_tree(path!("/test"), json!({})).await;
6877 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
6878
6879 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
6880 move |_, thread, mut cx| {
6881 async move {
6882 thread
6883 .update(&mut cx, |thread, cx| {
6884 thread.handle_session_update(
6885 acp::SessionUpdate::ToolCall(
6886 acp::ToolCall::new("test", "Label")
6887 .kind(acp::ToolKind::Edit)
6888 .status(acp::ToolCallStatus::Completed)
6889 .content(vec![acp::ToolCallContent::Diff(acp::Diff::new(
6890 "/test/test.txt",
6891 "foo",
6892 ))]),
6893 ),
6894 cx,
6895 )
6896 })
6897 .unwrap()
6898 .unwrap();
6899 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
6900 }
6901 .boxed_local()
6902 }
6903 }));
6904
6905 let thread = cx
6906 .update(|cx| {
6907 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6908 })
6909 .await
6910 .unwrap();
6911
6912 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Hi".into()], cx)))
6913 .await
6914 .unwrap();
6915
6916 assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls()));
6917 }
6918
6919 #[gpui::test(iterations = 10)]
6920 async fn test_checkpoints(cx: &mut TestAppContext) {
6921 init_test(cx);
6922 let fs = FakeFs::new(cx.background_executor.clone());
6923 fs.insert_tree(
6924 path!("/test"),
6925 json!({
6926 ".git": {}
6927 }),
6928 )
6929 .await;
6930 let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
6931
6932 let simulate_changes = Arc::new(AtomicBool::new(true));
6933 let next_filename = Arc::new(AtomicUsize::new(0));
6934 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
6935 let simulate_changes = simulate_changes.clone();
6936 let next_filename = next_filename.clone();
6937 let fs = fs.clone();
6938 move |request, thread, mut cx| {
6939 let fs = fs.clone();
6940 let simulate_changes = simulate_changes.clone();
6941 let next_filename = next_filename.clone();
6942 async move {
6943 if simulate_changes.load(SeqCst) {
6944 let filename = format!("/test/file-{}", next_filename.fetch_add(1, SeqCst));
6945 fs.write(Path::new(&filename), b"").await?;
6946 }
6947
6948 let acp::ContentBlock::Text(content) = &request.prompt[0] else {
6949 panic!("expected text content block");
6950 };
6951 thread.update(&mut cx, |thread, cx| {
6952 thread
6953 .handle_session_update(
6954 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
6955 content.text.to_uppercase().into(),
6956 )),
6957 cx,
6958 )
6959 .unwrap();
6960 })?;
6961 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
6962 }
6963 .boxed_local()
6964 }
6965 }));
6966 let thread = cx
6967 .update(|cx| {
6968 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
6969 })
6970 .await
6971 .unwrap();
6972
6973 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Lorem".into()], cx)))
6974 .await
6975 .unwrap();
6976 thread.read_with(cx, |thread, cx| {
6977 assert_eq!(
6978 thread.to_markdown(cx),
6979 indoc! {"
6980 ## User (checkpoint)
6981
6982 Lorem
6983
6984 ## Assistant
6985
6986 LOREM
6987
6988 "}
6989 );
6990 });
6991 assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
6992
6993 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["ipsum".into()], cx)))
6994 .await
6995 .unwrap();
6996 thread.read_with(cx, |thread, cx| {
6997 assert_eq!(
6998 thread.to_markdown(cx),
6999 indoc! {"
7000 ## User (checkpoint)
7001
7002 Lorem
7003
7004 ## Assistant
7005
7006 LOREM
7007
7008 ## User (checkpoint)
7009
7010 ipsum
7011
7012 ## Assistant
7013
7014 IPSUM
7015
7016 "}
7017 );
7018 });
7019 assert_eq!(
7020 fs.files(),
7021 vec![
7022 Path::new(path!("/test/file-0")),
7023 Path::new(path!("/test/file-1"))
7024 ]
7025 );
7026
7027 // Checkpoint isn't stored when there are no changes.
7028 simulate_changes.store(false, SeqCst);
7029 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["dolor".into()], cx)))
7030 .await
7031 .unwrap();
7032 thread.read_with(cx, |thread, cx| {
7033 assert_eq!(
7034 thread.to_markdown(cx),
7035 indoc! {"
7036 ## User (checkpoint)
7037
7038 Lorem
7039
7040 ## Assistant
7041
7042 LOREM
7043
7044 ## User (checkpoint)
7045
7046 ipsum
7047
7048 ## Assistant
7049
7050 IPSUM
7051
7052 ## User
7053
7054 dolor
7055
7056 ## Assistant
7057
7058 DOLOR
7059
7060 "}
7061 );
7062 });
7063 assert_eq!(
7064 fs.files(),
7065 vec![
7066 Path::new(path!("/test/file-0")),
7067 Path::new(path!("/test/file-1"))
7068 ]
7069 );
7070
7071 // Rewinding the conversation truncates the history and restores the checkpoint.
7072 thread
7073 .update(cx, |thread, cx| {
7074 let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
7075 panic!("unexpected entries {:?}", thread.entries)
7076 };
7077 thread.restore_checkpoint(message.client_id.clone().unwrap(), cx)
7078 })
7079 .await
7080 .unwrap();
7081 thread.read_with(cx, |thread, cx| {
7082 assert_eq!(
7083 thread.to_markdown(cx),
7084 indoc! {"
7085 ## User (checkpoint)
7086
7087 Lorem
7088
7089 ## Assistant
7090
7091 LOREM
7092
7093 "}
7094 );
7095 });
7096 assert_eq!(fs.files(), vec![Path::new(path!("/test/file-0"))]);
7097 }
7098
7099 #[gpui::test(iterations = 10)]
7100 async fn test_checkpoint_shows_when_file_changes_during_pending_message(
7101 cx: &mut TestAppContext,
7102 ) {
7103 init_test(cx);
7104 let fs = FakeFs::new(cx.background_executor.clone());
7105 fs.insert_tree(
7106 path!("/test"),
7107 json!({
7108 ".git": {}
7109 }),
7110 )
7111 .await;
7112 let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
7113
7114 let (request_started_tx, request_started_rx) = oneshot::channel::<()>();
7115 let request_started_tx = Rc::new(RefCell::new(Some(request_started_tx)));
7116 let (write_file_tx, write_file_rx) = oneshot::channel::<()>();
7117 let write_file_rx = Rc::new(RefCell::new(Some(write_file_rx)));
7118 let (file_written_tx, file_written_rx) = oneshot::channel::<()>();
7119 let file_written_tx = Rc::new(RefCell::new(Some(file_written_tx)));
7120 let (finish_response_tx, finish_response_rx) = oneshot::channel::<()>();
7121 let finish_response_tx = Rc::new(RefCell::new(Some(finish_response_tx)));
7122 let finish_response_rx = Rc::new(RefCell::new(Some(finish_response_rx)));
7123 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
7124 let request_started_tx = request_started_tx.clone();
7125 let write_file_rx = write_file_rx.clone();
7126 let file_written_tx = file_written_tx.clone();
7127 let finish_response_rx = finish_response_rx.clone();
7128 move |_request, thread, mut cx| {
7129 let write_file_rx = write_file_rx.borrow_mut().take();
7130 let finish_response_rx = finish_response_rx.borrow_mut().take();
7131 let request_started_tx = request_started_tx.borrow_mut().take();
7132 let file_written_tx = file_written_tx.borrow_mut().take();
7133 async move {
7134 if let Some(request_started_tx) = request_started_tx {
7135 request_started_tx.send(()).ok();
7136 }
7137 if let Some(write_file_rx) = write_file_rx {
7138 write_file_rx.await.ok();
7139 }
7140
7141 thread
7142 .update(&mut cx, |thread, cx| {
7143 thread.write_text_file(
7144 PathBuf::from(path!("/test/file")),
7145 String::new(),
7146 cx,
7147 )
7148 })?
7149 .await?;
7150
7151 if let Some(file_written_tx) = file_written_tx {
7152 file_written_tx.send(()).ok();
7153 }
7154 if let Some(finish_response_rx) = finish_response_rx {
7155 finish_response_rx.await.ok();
7156 }
7157
7158 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
7159 }
7160 .boxed_local()
7161 }
7162 }));
7163 let thread = cx
7164 .update(|cx| {
7165 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7166 })
7167 .await
7168 .unwrap();
7169
7170 let send = thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx));
7171 let send_task = cx.background_executor.spawn(send);
7172 request_started_rx.await.unwrap();
7173 cx.run_until_parked();
7174
7175 thread.read_with(cx, |thread, cx| {
7176 assert_eq!(
7177 thread.to_markdown(cx),
7178 indoc! {"
7179 ## User
7180
7181 hello
7182
7183 "}
7184 );
7185 });
7186
7187 write_file_tx.send(()).ok();
7188 file_written_rx.await.unwrap();
7189 cx.run_until_parked();
7190
7191 thread.read_with(cx, |thread, cx| {
7192 assert_eq!(
7193 thread.to_markdown(cx),
7194 indoc! {"
7195 ## User (checkpoint)
7196
7197 hello
7198
7199 "}
7200 );
7201 });
7202
7203 finish_response_tx
7204 .borrow_mut()
7205 .take()
7206 .unwrap()
7207 .send(())
7208 .ok();
7209 send_task.await.unwrap();
7210 }
7211
7212 #[gpui::test]
7213 async fn test_tool_result_refusal(cx: &mut TestAppContext) {
7214 use std::sync::atomic::AtomicUsize;
7215 init_test(cx);
7216
7217 let fs = FakeFs::new(cx.executor());
7218 let project = Project::test(fs, None, cx).await;
7219
7220 // Create a connection that simulates refusal after tool result
7221 let prompt_count = Arc::new(AtomicUsize::new(0));
7222 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
7223 let prompt_count = prompt_count.clone();
7224 move |_request, thread, mut cx| {
7225 let count = prompt_count.fetch_add(1, SeqCst);
7226 async move {
7227 if count == 0 {
7228 // First prompt: Generate a tool call with result
7229 thread.update(&mut cx, |thread, cx| {
7230 thread
7231 .handle_session_update(
7232 acp::SessionUpdate::ToolCall(
7233 acp::ToolCall::new("tool1", "Test Tool")
7234 .kind(acp::ToolKind::Fetch)
7235 .status(acp::ToolCallStatus::Completed)
7236 .raw_input(serde_json::json!({"query": "test"}))
7237 .raw_output(serde_json::json!({"result": "inappropriate content"})),
7238 ),
7239 cx,
7240 )
7241 .unwrap();
7242 })?;
7243
7244 // Now return refusal because of the tool result
7245 Ok(acp::PromptResponse::new(acp::StopReason::Refusal))
7246 } else {
7247 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
7248 }
7249 }
7250 .boxed_local()
7251 }
7252 }));
7253
7254 let thread = cx
7255 .update(|cx| {
7256 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7257 })
7258 .await
7259 .unwrap();
7260
7261 // Track if we see a Refusal event
7262 let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
7263 let saw_refusal_event_captured = saw_refusal_event.clone();
7264 thread.update(cx, |_thread, cx| {
7265 cx.subscribe(
7266 &thread,
7267 move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
7268 if matches!(event, AcpThreadEvent::Refusal) {
7269 *saw_refusal_event_captured.lock().unwrap() = true;
7270 }
7271 },
7272 )
7273 .detach();
7274 });
7275
7276 // Send a user message - this will trigger tool call and then refusal
7277 let send_task = thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
7278 cx.background_executor.spawn(send_task).detach();
7279 cx.run_until_parked();
7280
7281 // Verify that:
7282 // 1. A Refusal event WAS emitted (because it's a tool result refusal, not user prompt)
7283 // 2. The user message was NOT truncated
7284 assert!(
7285 *saw_refusal_event.lock().unwrap(),
7286 "Refusal event should be emitted for tool result refusals"
7287 );
7288
7289 thread.read_with(cx, |thread, _| {
7290 let entries = thread.entries();
7291 assert!(entries.len() >= 2, "Should have user message and tool call");
7292
7293 // Verify user message is still there
7294 assert!(
7295 matches!(entries[0], AgentThreadEntry::UserMessage(_)),
7296 "User message should not be truncated"
7297 );
7298
7299 // Verify tool call is there with result
7300 if let AgentThreadEntry::ToolCall(tool_call) = &entries[1] {
7301 assert!(
7302 tool_call.raw_output.is_some(),
7303 "Tool call should have output"
7304 );
7305 } else {
7306 panic!("Expected tool call at index 1");
7307 }
7308 });
7309 }
7310
7311 #[gpui::test]
7312 async fn test_user_prompt_refusal_emits_event(cx: &mut TestAppContext) {
7313 init_test(cx);
7314
7315 let fs = FakeFs::new(cx.executor());
7316 let project = Project::test(fs, None, cx).await;
7317
7318 let refuse_next = Arc::new(AtomicBool::new(false));
7319 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
7320 let refuse_next = refuse_next.clone();
7321 move |_request, _thread, _cx| {
7322 if refuse_next.load(SeqCst) {
7323 async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) }
7324 .boxed_local()
7325 } else {
7326 async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }
7327 .boxed_local()
7328 }
7329 }
7330 }));
7331
7332 let thread = cx
7333 .update(|cx| {
7334 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7335 })
7336 .await
7337 .unwrap();
7338
7339 // Track if we see a Refusal event
7340 let saw_refusal_event = Arc::new(std::sync::Mutex::new(false));
7341 let saw_refusal_event_captured = saw_refusal_event.clone();
7342 thread.update(cx, |_thread, cx| {
7343 cx.subscribe(
7344 &thread,
7345 move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
7346 if matches!(event, AcpThreadEvent::Refusal) {
7347 *saw_refusal_event_captured.lock().unwrap() = true;
7348 }
7349 },
7350 )
7351 .detach();
7352 });
7353
7354 // Send a message that will be refused
7355 refuse_next.store(true, SeqCst);
7356 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
7357 .await
7358 .unwrap();
7359
7360 // Verify that a Refusal event WAS emitted for user prompt refusal
7361 assert!(
7362 *saw_refusal_event.lock().unwrap(),
7363 "Refusal event should be emitted for user prompt refusals"
7364 );
7365
7366 // Verify the message was truncated (user prompt refusal)
7367 thread.read_with(cx, |thread, cx| {
7368 assert_eq!(thread.to_markdown(cx), "");
7369 });
7370 }
7371
7372 #[gpui::test]
7373 async fn test_refusal(cx: &mut TestAppContext) {
7374 init_test(cx);
7375 let fs = FakeFs::new(cx.background_executor.clone());
7376 fs.insert_tree(path!("/"), json!({})).await;
7377 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
7378
7379 let refuse_next = Arc::new(AtomicBool::new(false));
7380 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
7381 let refuse_next = refuse_next.clone();
7382 move |request, thread, mut cx| {
7383 let refuse_next = refuse_next.clone();
7384 async move {
7385 if refuse_next.load(SeqCst) {
7386 return Ok(acp::PromptResponse::new(acp::StopReason::Refusal));
7387 }
7388
7389 let acp::ContentBlock::Text(content) = &request.prompt[0] else {
7390 panic!("expected text content block");
7391 };
7392 thread.update(&mut cx, |thread, cx| {
7393 thread
7394 .handle_session_update(
7395 acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
7396 content.text.to_uppercase().into(),
7397 )),
7398 cx,
7399 )
7400 .unwrap();
7401 })?;
7402 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
7403 }
7404 .boxed_local()
7405 }
7406 }));
7407 let thread = cx
7408 .update(|cx| {
7409 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7410 })
7411 .await
7412 .unwrap();
7413
7414 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["hello".into()], cx)))
7415 .await
7416 .unwrap();
7417 thread.read_with(cx, |thread, cx| {
7418 assert_eq!(
7419 thread.to_markdown(cx),
7420 indoc! {"
7421 ## User
7422
7423 hello
7424
7425 ## Assistant
7426
7427 HELLO
7428
7429 "}
7430 );
7431 });
7432
7433 // Simulate refusing the second message. The message should be truncated
7434 // when a user prompt is refused.
7435 refuse_next.store(true, SeqCst);
7436 cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["world".into()], cx)))
7437 .await
7438 .unwrap();
7439 thread.read_with(cx, |thread, cx| {
7440 assert_eq!(
7441 thread.to_markdown(cx),
7442 indoc! {"
7443 ## User
7444
7445 hello
7446
7447 ## Assistant
7448
7449 HELLO
7450
7451 "}
7452 );
7453 });
7454 }
7455
7456 async fn new_test_thread(cx: &mut TestAppContext) -> Entity<AcpThread> {
7457 let fs = FakeFs::new(cx.executor());
7458 let project = Project::test(fs, [], cx).await;
7459 let connection = Rc::new(FakeAgentConnection::new());
7460 cx.update(|cx| {
7461 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7462 })
7463 .await
7464 .unwrap()
7465 }
7466
7467 fn only_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) {
7468 let [entry] = thread.entries() else {
7469 panic!("expected one elicitation entry, got {:?}", thread.entries());
7470 };
7471 let AgentThreadEntry::Elicitation(id) = entry else {
7472 panic!("expected one elicitation entry, got {:?}", thread.entries());
7473 };
7474 let Some((_, elicitation)) = thread.elicitation(id) else {
7475 panic!("missing elicitation entry");
7476 };
7477 (id.clone(), elicitation)
7478 }
7479
7480 fn latest_thread_elicitation(thread: &AcpThread) -> (ElicitationEntryId, &Elicitation) {
7481 let Some(AgentThreadEntry::Elicitation(id)) = thread.entries().last() else {
7482 panic!("expected latest entry to be an elicitation");
7483 };
7484 let Some((_, elicitation)) = thread.elicitation(id) else {
7485 panic!("missing elicitation entry");
7486 };
7487 (id.clone(), elicitation)
7488 }
7489
7490 #[gpui::test]
7491 async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) {
7492 init_test(cx);
7493 cx.update(|cx| {
7494 cx.update_flags(false, vec![]);
7495 });
7496 set_acp_beta_override("off", cx);
7497 let thread = new_test_thread(cx).await;
7498 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7499
7500 let result = thread.update(cx, |thread, cx| {
7501 thread.request_elicitation(
7502 acp::CreateElicitationRequest::new(
7503 acp::ElicitationFormMode::new(
7504 acp::ElicitationSessionScope::new(session_id),
7505 acp::ElicitationSchema::new().string("name", true),
7506 ),
7507 "Provide a name",
7508 ),
7509 cx,
7510 )
7511 });
7512
7513 assert!(result.is_ok());
7514 thread.read_with(cx, |thread, _| {
7515 assert!(matches!(
7516 thread.entries(),
7517 [AgentThreadEntry::Elicitation(_)]
7518 ));
7519 });
7520 }
7521
7522 #[gpui::test]
7523 async fn test_form_elicitation_accepts_response(cx: &mut TestAppContext) {
7524 init_test(cx);
7525 enable_acp_beta(cx);
7526 let thread = new_test_thread(cx).await;
7527 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7528 let tool_call_id = acp::ToolCallId::new("tool-1");
7529
7530 let response_task = thread.update(cx, |thread, cx| {
7531 thread
7532 .request_elicitation(
7533 acp::CreateElicitationRequest::new(
7534 acp::ElicitationFormMode::new(
7535 acp::ElicitationSessionScope::new(session_id.clone())
7536 .tool_call_id(tool_call_id.clone()),
7537 acp::ElicitationSchema::new().string("name", true),
7538 ),
7539 "Provide a name",
7540 ),
7541 cx,
7542 )
7543 .unwrap()
7544 });
7545
7546 let elicitation_id = thread.read_with(cx, |thread, _| {
7547 let (elicitation_id, elicitation) = only_thread_elicitation(thread);
7548 let acp::ElicitationScope::Session(scope) = elicitation.request.scope() else {
7549 panic!("expected session-scoped elicitation");
7550 };
7551 assert_eq!(scope.tool_call_id.as_ref(), Some(&tool_call_id));
7552 elicitation_id
7553 });
7554
7555 let expected_content = std::collections::BTreeMap::from([(
7556 "name".to_string(),
7557 acp::ElicitationContentValue::from("Ada"),
7558 )]);
7559 thread.update(cx, |thread, cx| {
7560 thread.respond_to_elicitation(
7561 &elicitation_id,
7562 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
7563 acp::ElicitationAcceptAction::new().content(expected_content.clone()),
7564 )),
7565 cx,
7566 );
7567 });
7568
7569 let response = response_task.await;
7570 assert_eq!(
7571 response.action,
7572 acp::ElicitationAction::Accept(
7573 acp::ElicitationAcceptAction::new().content(expected_content)
7574 )
7575 );
7576 thread.read_with(cx, |thread, _| {
7577 let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
7578 panic!("missing elicitation entry");
7579 };
7580 assert!(matches!(elicitation.status, ElicitationStatus::Accepted));
7581 });
7582 }
7583
7584 #[gpui::test]
7585 async fn test_url_elicitation_can_be_completed(cx: &mut TestAppContext) {
7586 init_test(cx);
7587 enable_acp_beta(cx);
7588 let thread = new_test_thread(cx).await;
7589 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7590 let url_elicitation_id = acp::ElicitationId::new("url-1");
7591
7592 let response_task = thread.update(cx, |thread, cx| {
7593 thread
7594 .request_elicitation(
7595 acp::CreateElicitationRequest::new(
7596 acp::ElicitationUrlMode::new(
7597 acp::ElicitationSessionScope::new(session_id),
7598 url_elicitation_id.clone(),
7599 "https://example.com/complete",
7600 ),
7601 "Complete this in the browser",
7602 ),
7603 cx,
7604 )
7605 .unwrap()
7606 });
7607
7608 let entry_id = thread.read_with(cx, |thread, _| {
7609 let (entry_id, _) = only_thread_elicitation(thread);
7610 entry_id
7611 });
7612
7613 thread.update(cx, |thread, cx| {
7614 thread.complete_url_elicitation(&url_elicitation_id, cx);
7615 });
7616 assert!(matches!(
7617 response_task.await.action,
7618 acp::ElicitationAction::Accept(_)
7619 ));
7620 thread.update(cx, |thread, cx| {
7621 thread.respond_to_elicitation(
7622 &entry_id,
7623 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
7624 cx,
7625 );
7626 });
7627 thread.read_with(cx, |thread, _| {
7628 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7629 panic!("missing elicitation entry");
7630 };
7631 assert!(matches!(elicitation.status, ElicitationStatus::Completed));
7632 });
7633 }
7634
7635 #[gpui::test]
7636 async fn test_idle_cancel_cancels_accepted_url_elicitation(cx: &mut TestAppContext) {
7637 init_test(cx);
7638 enable_acp_beta(cx);
7639 let thread = new_test_thread(cx).await;
7640 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7641 let url_elicitation_id = acp::ElicitationId::new("url-1");
7642
7643 let response_task = thread.update(cx, |thread, cx| {
7644 thread
7645 .request_elicitation(
7646 acp::CreateElicitationRequest::new(
7647 acp::ElicitationUrlMode::new(
7648 acp::ElicitationSessionScope::new(session_id),
7649 url_elicitation_id.clone(),
7650 "https://example.com/complete",
7651 ),
7652 "Complete this in the browser",
7653 ),
7654 cx,
7655 )
7656 .unwrap()
7657 });
7658
7659 let entry_id = thread.read_with(cx, |thread, _| {
7660 let (entry_id, _) = only_thread_elicitation(thread);
7661 entry_id
7662 });
7663
7664 thread.update(cx, |thread, cx| {
7665 thread.respond_to_elicitation(
7666 &entry_id,
7667 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
7668 acp::ElicitationAcceptAction::new(),
7669 )),
7670 cx,
7671 );
7672 });
7673 assert!(matches!(
7674 response_task.await.action,
7675 acp::ElicitationAction::Accept(_)
7676 ));
7677
7678 thread.update(cx, |thread, cx| {
7679 thread.cancel(cx).detach();
7680 });
7681 thread.read_with(cx, |thread, _| {
7682 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7683 panic!("missing elicitation entry");
7684 };
7685 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7686 });
7687
7688 thread.update(cx, |thread, cx| {
7689 thread.complete_url_elicitation(&url_elicitation_id, cx);
7690 });
7691 thread.read_with(cx, |thread, _| {
7692 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7693 panic!("missing elicitation entry");
7694 };
7695 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7696 });
7697 }
7698
7699 #[gpui::test]
7700 async fn test_cancel_accepted_url_elicitation_marks_canceled(cx: &mut TestAppContext) {
7701 init_test(cx);
7702 enable_acp_beta(cx);
7703 let thread = new_test_thread(cx).await;
7704 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7705 let url_elicitation_id = acp::ElicitationId::new("url-1");
7706
7707 let response_task = thread.update(cx, |thread, cx| {
7708 thread
7709 .request_elicitation(
7710 acp::CreateElicitationRequest::new(
7711 acp::ElicitationUrlMode::new(
7712 acp::ElicitationSessionScope::new(session_id),
7713 url_elicitation_id.clone(),
7714 "https://example.com/complete",
7715 ),
7716 "Complete this in the browser",
7717 ),
7718 cx,
7719 )
7720 .unwrap()
7721 });
7722
7723 let entry_id = thread.read_with(cx, |thread, _| {
7724 let (entry_id, _) = only_thread_elicitation(thread);
7725 entry_id
7726 });
7727
7728 thread.update(cx, |thread, cx| {
7729 thread.respond_to_elicitation(
7730 &entry_id,
7731 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
7732 acp::ElicitationAcceptAction::new(),
7733 )),
7734 cx,
7735 );
7736 });
7737 assert!(matches!(
7738 response_task.await.action,
7739 acp::ElicitationAction::Accept(_)
7740 ));
7741 thread.read_with(cx, |thread, _| {
7742 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7743 panic!("missing elicitation entry");
7744 };
7745 assert!(matches!(elicitation.status, ElicitationStatus::Accepted));
7746 });
7747
7748 thread.update(cx, |thread, cx| {
7749 thread.cancel(cx).detach();
7750 });
7751 thread.read_with(cx, |thread, _| {
7752 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7753 panic!("missing elicitation entry");
7754 };
7755 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7756 });
7757
7758 thread.update(cx, |thread, cx| {
7759 thread.complete_url_elicitation(&url_elicitation_id, cx);
7760 });
7761 thread.read_with(cx, |thread, _| {
7762 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7763 panic!("missing elicitation entry");
7764 };
7765 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7766 });
7767 }
7768
7769 #[gpui::test]
7770 async fn test_turn_cancel_cancels_accepted_url_elicitation_from_previous_turn(
7771 cx: &mut TestAppContext,
7772 ) {
7773 init_test(cx);
7774 enable_acp_beta(cx);
7775 let fs = FakeFs::new(cx.executor());
7776 let project = Project::test(fs, [], cx).await;
7777 let prompt_count = Rc::new(RefCell::new(0usize));
7778 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
7779 let prompt_count = prompt_count.clone();
7780 move |_request, _thread, _cx| {
7781 let stop_reason = {
7782 let mut prompt_count = prompt_count.borrow_mut();
7783 let stop_reason = if *prompt_count == 0 {
7784 acp::StopReason::EndTurn
7785 } else {
7786 acp::StopReason::Cancelled
7787 };
7788 *prompt_count += 1;
7789 stop_reason
7790 };
7791
7792 async move { Ok(acp::PromptResponse::new(stop_reason)) }.boxed_local()
7793 }
7794 }));
7795 let thread = cx
7796 .update(|cx| {
7797 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
7798 })
7799 .await
7800 .expect("new session should succeed");
7801
7802 let response = thread
7803 .update(cx, |thread, cx| thread.send(vec!["first turn".into()], cx))
7804 .await
7805 .expect("first turn should succeed")
7806 .expect("first turn should return a response");
7807 assert_eq!(response.stop_reason, acp::StopReason::EndTurn);
7808
7809 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7810 let url_elicitation_id = acp::ElicitationId::new("url-1");
7811 let response_task = thread.update(cx, |thread, cx| {
7812 thread
7813 .request_elicitation(
7814 acp::CreateElicitationRequest::new(
7815 acp::ElicitationUrlMode::new(
7816 acp::ElicitationSessionScope::new(session_id),
7817 url_elicitation_id.clone(),
7818 "https://example.com/complete",
7819 ),
7820 "Complete this in the browser",
7821 ),
7822 cx,
7823 )
7824 .expect("url elicitation should be accepted")
7825 });
7826
7827 let entry_id = thread.read_with(cx, |thread, _| {
7828 let (entry_id, _) = latest_thread_elicitation(thread);
7829 entry_id
7830 });
7831
7832 thread.update(cx, |thread, cx| {
7833 thread.respond_to_elicitation(
7834 &entry_id,
7835 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
7836 acp::ElicitationAcceptAction::new(),
7837 )),
7838 cx,
7839 );
7840 });
7841 assert!(matches!(
7842 response_task.await.action,
7843 acp::ElicitationAction::Accept(_)
7844 ));
7845
7846 let response = thread
7847 .update(cx, |thread, cx| thread.send(vec!["second turn".into()], cx))
7848 .await
7849 .expect("second turn should succeed")
7850 .expect("second turn should return a response");
7851 assert_eq!(response.stop_reason, acp::StopReason::Cancelled);
7852 thread.read_with(cx, |thread, _| {
7853 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7854 panic!("missing elicitation entry");
7855 };
7856 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7857 });
7858
7859 thread.update(cx, |thread, cx| {
7860 thread.complete_url_elicitation(&url_elicitation_id, cx);
7861 });
7862 thread.read_with(cx, |thread, _| {
7863 let Some((_, elicitation)) = thread.elicitation(&entry_id) else {
7864 panic!("missing elicitation entry");
7865 };
7866 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
7867 });
7868 }
7869
7870 #[gpui::test]
7871 async fn test_request_scoped_elicitation_store_accepts_response(cx: &mut TestAppContext) {
7872 init_test(cx);
7873 enable_acp_beta(cx);
7874 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
7875
7876 let response_task = store.update(cx, |store, cx| {
7877 store
7878 .request_elicitation(
7879 acp::CreateElicitationRequest::new(
7880 acp::ElicitationFormMode::new(
7881 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
7882 acp::ElicitationSchema::new().string("name", true),
7883 ),
7884 "Provide a name",
7885 ),
7886 cx,
7887 )
7888 .unwrap()
7889 });
7890
7891 let elicitation_id = store.read_with(cx, |store, _| {
7892 let [elicitation] = store.elicitations() else {
7893 panic!(
7894 "expected one elicitation entry, got {:?}",
7895 store.elicitations()
7896 );
7897 };
7898 let acp::ElicitationScope::Request(scope) = elicitation.request.scope() else {
7899 panic!("expected request-scoped elicitation");
7900 };
7901 assert_eq!(scope.request_id, acp::RequestId::Number(1));
7902 elicitation.id.clone()
7903 });
7904
7905 store.update(cx, |store, cx| {
7906 store.respond_to_elicitation(
7907 &elicitation_id,
7908 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
7909 cx,
7910 );
7911 });
7912
7913 assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
7914 store.read_with(cx, |store, _| {
7915 let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
7916 panic!("missing elicitation entry");
7917 };
7918 assert!(matches!(elicitation.status, ElicitationStatus::Declined));
7919 });
7920 }
7921
7922 #[gpui::test]
7923 async fn test_request_elicitation_store_ignores_duplicate_response(cx: &mut TestAppContext) {
7924 init_test(cx);
7925 enable_acp_beta(cx);
7926 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
7927
7928 let response_task = store.update(cx, |store, cx| {
7929 store
7930 .request_elicitation(
7931 acp::CreateElicitationRequest::new(
7932 acp::ElicitationFormMode::new(
7933 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
7934 acp::ElicitationSchema::new().string("name", true),
7935 ),
7936 "Provide a name",
7937 ),
7938 cx,
7939 )
7940 .unwrap()
7941 });
7942
7943 let elicitation_id = store.read_with(cx, |store, _| {
7944 let [elicitation] = store.elicitations() else {
7945 panic!(
7946 "expected one elicitation entry, got {:?}",
7947 store.elicitations()
7948 );
7949 };
7950 elicitation.id.clone()
7951 });
7952
7953 store.update(cx, |store, cx| {
7954 store.respond_to_elicitation(
7955 &elicitation_id,
7956 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
7957 cx,
7958 );
7959 store.respond_to_elicitation(
7960 &elicitation_id,
7961 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
7962 acp::ElicitationAcceptAction::new(),
7963 )),
7964 cx,
7965 );
7966 });
7967
7968 assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
7969 store.read_with(cx, |store, _| {
7970 let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
7971 panic!("missing elicitation entry");
7972 };
7973 assert!(matches!(elicitation.status, ElicitationStatus::Declined));
7974 });
7975 }
7976
7977 #[gpui::test]
7978 async fn test_cancel_session_elicitation_by_id_resolves_cancel(cx: &mut TestAppContext) {
7979 init_test(cx);
7980 enable_acp_beta(cx);
7981 let thread = new_test_thread(cx).await;
7982 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
7983
7984 let (elicitation_id, response_task) = thread.update(cx, |thread, cx| {
7985 thread
7986 .request_elicitation_with_id(
7987 acp::CreateElicitationRequest::new(
7988 acp::ElicitationFormMode::new(
7989 acp::ElicitationSessionScope::new(session_id),
7990 acp::ElicitationSchema::new().string("name", true),
7991 ),
7992 "Provide a name",
7993 ),
7994 cx,
7995 )
7996 .unwrap()
7997 });
7998
7999 thread.update(cx, |thread, cx| {
8000 thread.cancel_elicitation(&elicitation_id, cx);
8001 });
8002
8003 assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
8004 thread.read_with(cx, |thread, _| {
8005 let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
8006 panic!("missing elicitation entry");
8007 };
8008 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8009 });
8010 }
8011
8012 #[gpui::test]
8013 async fn test_cancel_pending_session_elicitation_resolves_cancel(cx: &mut TestAppContext) {
8014 init_test(cx);
8015 enable_acp_beta(cx);
8016 let thread = new_test_thread(cx).await;
8017 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
8018
8019 let response_task = thread.update(cx, |thread, cx| {
8020 thread
8021 .request_elicitation(
8022 acp::CreateElicitationRequest::new(
8023 acp::ElicitationFormMode::new(
8024 acp::ElicitationSessionScope::new(session_id),
8025 acp::ElicitationSchema::new().string("name", true),
8026 ),
8027 "Provide a name",
8028 ),
8029 cx,
8030 )
8031 .unwrap()
8032 });
8033
8034 let elicitation_id = thread.read_with(cx, |thread, _| {
8035 let (elicitation_id, _) = only_thread_elicitation(thread);
8036 elicitation_id
8037 });
8038
8039 thread.update(cx, |thread, cx| {
8040 thread.cancel(cx).detach();
8041 });
8042
8043 assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
8044 thread.read_with(cx, |thread, _| {
8045 let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
8046 panic!("missing elicitation entry");
8047 };
8048 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8049 });
8050 }
8051
8052 fn request_test_session_elicitation(
8053 thread: WeakEntity<AcpThread>,
8054 session_id: acp::SessionId,
8055 cx: &mut AsyncApp,
8056 ) -> Result<Task<acp::CreateElicitationResponse>> {
8057 thread.update(cx, |thread, cx| {
8058 thread
8059 .request_elicitation(
8060 acp::CreateElicitationRequest::new(
8061 acp::ElicitationFormMode::new(
8062 acp::ElicitationSessionScope::new(session_id),
8063 acp::ElicitationSchema::new().string("name", true),
8064 ),
8065 "Provide a name",
8066 ),
8067 cx,
8068 )
8069 .map_err(|error| anyhow!(error))
8070 })?
8071 }
8072
8073 #[gpui::test]
8074 async fn test_prompt_error_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
8075 init_test(cx);
8076 enable_acp_beta(cx);
8077 let fs = FakeFs::new(cx.executor());
8078 let project = Project::test(fs, [], cx).await;
8079 let elicitation_action = Rc::new(RefCell::new(None));
8080 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
8081 let elicitation_action = elicitation_action.clone();
8082 move |request, thread, mut cx| {
8083 let elicitation_action = elicitation_action.clone();
8084 async move {
8085 let response_task =
8086 request_test_session_elicitation(thread, request.session_id, &mut cx)?;
8087 cx.spawn(async move |_cx| {
8088 let response = response_task.await;
8089 *elicitation_action.borrow_mut() = Some(response.action);
8090 })
8091 .detach();
8092
8093 Err(anyhow!("prompt failed"))
8094 }
8095 .boxed_local()
8096 }
8097 }));
8098 let thread = cx
8099 .update(|cx| {
8100 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
8101 })
8102 .await
8103 .expect("new session should succeed");
8104
8105 let result = thread
8106 .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))
8107 .await;
8108
8109 assert!(result.is_err());
8110 cx.run_until_parked();
8111 assert_eq!(
8112 *elicitation_action.borrow(),
8113 Some(acp::ElicitationAction::Cancel)
8114 );
8115 thread.read_with(cx, |thread, _| {
8116 let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry {
8117 AgentThreadEntry::Elicitation(id) => {
8118 thread.elicitation(id).map(|(_, elicitation)| elicitation)
8119 }
8120 _ => None,
8121 }) else {
8122 panic!("expected an elicitation entry");
8123 };
8124 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8125 });
8126 }
8127
8128 #[gpui::test]
8129 async fn test_max_tokens_cancels_pending_session_elicitation(cx: &mut TestAppContext) {
8130 init_test(cx);
8131 enable_acp_beta(cx);
8132 let fs = FakeFs::new(cx.executor());
8133 let project = Project::test(fs, [], cx).await;
8134 let elicitation_action = Rc::new(RefCell::new(None));
8135 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
8136 let elicitation_action = elicitation_action.clone();
8137 move |request, thread, mut cx| {
8138 let elicitation_action = elicitation_action.clone();
8139 async move {
8140 let response_task =
8141 request_test_session_elicitation(thread, request.session_id, &mut cx)?;
8142 cx.spawn(async move |_cx| {
8143 let response = response_task.await;
8144 *elicitation_action.borrow_mut() = Some(response.action);
8145 })
8146 .detach();
8147
8148 Ok(acp::PromptResponse::new(acp::StopReason::MaxTokens))
8149 }
8150 .boxed_local()
8151 }
8152 }));
8153 let thread = cx
8154 .update(|cx| {
8155 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
8156 })
8157 .await
8158 .expect("new session should succeed");
8159
8160 let result = thread
8161 .update(cx, |thread, cx| thread.send(vec!["hello".into()], cx))
8162 .await;
8163
8164 assert!(result.is_err());
8165 cx.run_until_parked();
8166 assert_eq!(
8167 *elicitation_action.borrow(),
8168 Some(acp::ElicitationAction::Cancel)
8169 );
8170 thread.read_with(cx, |thread, _| {
8171 let Some(elicitation) = thread.entries().iter().find_map(|entry| match entry {
8172 AgentThreadEntry::Elicitation(id) => {
8173 thread.elicitation(id).map(|(_, elicitation)| elicitation)
8174 }
8175 _ => None,
8176 }) else {
8177 panic!("expected an elicitation entry");
8178 };
8179 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8180 });
8181 }
8182
8183 #[gpui::test]
8184 async fn test_cancel_request_scoped_elicitation_resolves_cancel(cx: &mut TestAppContext) {
8185 init_test(cx);
8186 enable_acp_beta(cx);
8187 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8188
8189 let (elicitation_id, response_task) = store.update(cx, |store, cx| {
8190 store
8191 .request_elicitation_with_id(
8192 acp::CreateElicitationRequest::new(
8193 acp::ElicitationFormMode::new(
8194 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8195 acp::ElicitationSchema::new().string("name", true),
8196 ),
8197 "Provide a name",
8198 ),
8199 cx,
8200 )
8201 .unwrap()
8202 });
8203
8204 store.update(cx, |store, cx| {
8205 store.cancel_elicitation(&elicitation_id, cx);
8206 });
8207
8208 assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
8209 store.read_with(cx, |store, _| {
8210 let Some((_, elicitation)) = store.elicitation(&elicitation_id) else {
8211 panic!("missing elicitation entry");
8212 };
8213 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8214 });
8215 }
8216
8217 #[gpui::test]
8218 async fn test_request_elicitation_store_cancel_all_resolves_cancel(cx: &mut TestAppContext) {
8219 init_test(cx);
8220 enable_acp_beta(cx);
8221 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8222
8223 let response_task = store.update(cx, |store, cx| {
8224 store
8225 .request_elicitation(
8226 acp::CreateElicitationRequest::new(
8227 acp::ElicitationFormMode::new(
8228 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8229 acp::ElicitationSchema::new().string("name", true),
8230 ),
8231 "Provide a name",
8232 ),
8233 cx,
8234 )
8235 .unwrap()
8236 });
8237
8238 store.update(cx, |store, cx| {
8239 store.cancel_all(cx);
8240 });
8241
8242 assert_eq!(response_task.await.action, acp::ElicitationAction::Cancel);
8243 }
8244
8245 #[gpui::test]
8246 async fn test_request_elicitation_store_clear_removes_answered_and_cancels_pending(
8247 cx: &mut TestAppContext,
8248 ) {
8249 init_test(cx);
8250 enable_acp_beta(cx);
8251 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8252
8253 let first_response_task = store.update(cx, |store, cx| {
8254 store
8255 .request_elicitation(
8256 acp::CreateElicitationRequest::new(
8257 acp::ElicitationFormMode::new(
8258 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8259 acp::ElicitationSchema::new().string("name", true),
8260 ),
8261 "Provide a name",
8262 ),
8263 cx,
8264 )
8265 .unwrap()
8266 });
8267 let second_response_task = store.update(cx, |store, cx| {
8268 store
8269 .request_elicitation(
8270 acp::CreateElicitationRequest::new(
8271 acp::ElicitationFormMode::new(
8272 acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
8273 acp::ElicitationSchema::new().string("account", true),
8274 ),
8275 "Provide an account",
8276 ),
8277 cx,
8278 )
8279 .unwrap()
8280 });
8281
8282 let first_elicitation_id = store.read_with(cx, |store, _| {
8283 let [first, _second] = store.elicitations() else {
8284 panic!("expected two elicitations, got {:?}", store.elicitations());
8285 };
8286 first.id.clone()
8287 });
8288
8289 store.update(cx, |store, cx| {
8290 store.respond_to_elicitation(
8291 &first_elicitation_id,
8292 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
8293 cx,
8294 );
8295 store.clear(cx);
8296 });
8297
8298 assert_eq!(
8299 first_response_task.await.action,
8300 acp::ElicitationAction::Decline
8301 );
8302 assert_eq!(
8303 second_response_task.await.action,
8304 acp::ElicitationAction::Cancel
8305 );
8306 store.read_with(cx, |store, _| assert!(store.elicitations().is_empty()));
8307 }
8308
8309 #[gpui::test]
8310 async fn test_request_elicitation_store_clear_resolved_preserves_outstanding(
8311 cx: &mut TestAppContext,
8312 ) {
8313 init_test(cx);
8314 enable_acp_beta(cx);
8315 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8316 let url_elicitation_id = acp::ElicitationId::new("url-1");
8317
8318 let accepted_response_task = store.update(cx, |store, cx| {
8319 store
8320 .request_elicitation(
8321 acp::CreateElicitationRequest::new(
8322 acp::ElicitationFormMode::new(
8323 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8324 acp::ElicitationSchema::new().string("name", true),
8325 ),
8326 "Provide a name",
8327 ),
8328 cx,
8329 )
8330 .unwrap()
8331 });
8332 let pending_response_task = store.update(cx, |store, cx| {
8333 store
8334 .request_elicitation(
8335 acp::CreateElicitationRequest::new(
8336 acp::ElicitationFormMode::new(
8337 acp::ElicitationRequestScope::new(acp::RequestId::Number(2)),
8338 acp::ElicitationSchema::new().string("account", true),
8339 ),
8340 "Provide an account",
8341 ),
8342 cx,
8343 )
8344 .unwrap()
8345 });
8346 let accepted_url_response_task = store.update(cx, |store, cx| {
8347 store
8348 .request_elicitation(
8349 acp::CreateElicitationRequest::new(
8350 acp::ElicitationUrlMode::new(
8351 acp::ElicitationRequestScope::new(acp::RequestId::Number(3)),
8352 url_elicitation_id,
8353 "https://example.com/complete",
8354 ),
8355 "Complete this in the browser",
8356 ),
8357 cx,
8358 )
8359 .unwrap()
8360 });
8361
8362 let (accepted_id, pending_id, accepted_url_id) = store.read_with(cx, |store, _| {
8363 let [accepted, pending, accepted_url] = store.elicitations() else {
8364 panic!(
8365 "expected three request-scoped elicitations, got {:?}",
8366 store.elicitations()
8367 );
8368 };
8369 (
8370 accepted.id.clone(),
8371 pending.id.clone(),
8372 accepted_url.id.clone(),
8373 )
8374 });
8375
8376 store.update(cx, |store, cx| {
8377 store.respond_to_elicitation(
8378 &accepted_id,
8379 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
8380 acp::ElicitationAcceptAction::new(),
8381 )),
8382 cx,
8383 );
8384 store.respond_to_elicitation(
8385 &accepted_url_id,
8386 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
8387 acp::ElicitationAcceptAction::new(),
8388 )),
8389 cx,
8390 );
8391 });
8392 assert!(matches!(
8393 accepted_response_task.await.action,
8394 acp::ElicitationAction::Accept(_)
8395 ));
8396 assert!(matches!(
8397 accepted_url_response_task.await.action,
8398 acp::ElicitationAction::Accept(_)
8399 ));
8400
8401 let cleared_ids = store.update(cx, |store, cx| store.clear_resolved(cx));
8402 assert_eq!(cleared_ids, vec![accepted_id]);
8403 store.read_with(cx, |store, _| {
8404 let [pending, accepted_url] = store.elicitations() else {
8405 panic!(
8406 "expected pending and accepted url elicitations, got {:?}",
8407 store.elicitations()
8408 );
8409 };
8410 assert_eq!(pending.id, pending_id);
8411 assert!(matches!(pending.status, ElicitationStatus::Pending { .. }));
8412 assert_eq!(accepted_url.id, accepted_url_id);
8413 assert!(matches!(accepted_url.status, ElicitationStatus::Accepted));
8414 });
8415
8416 store.update(cx, |store, cx| store.clear(cx));
8417 assert_eq!(
8418 pending_response_task.await.action,
8419 acp::ElicitationAction::Cancel
8420 );
8421 }
8422
8423 #[gpui::test]
8424 async fn test_request_url_elicitation_store_can_be_completed(cx: &mut TestAppContext) {
8425 init_test(cx);
8426 enable_acp_beta(cx);
8427 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8428 let url_elicitation_id = acp::ElicitationId::new("url-1");
8429
8430 let response_task = store.update(cx, |store, cx| {
8431 store
8432 .request_elicitation(
8433 acp::CreateElicitationRequest::new(
8434 acp::ElicitationUrlMode::new(
8435 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8436 url_elicitation_id.clone(),
8437 "https://example.com/complete",
8438 ),
8439 "Complete this in the browser",
8440 ),
8441 cx,
8442 )
8443 .unwrap()
8444 });
8445
8446 let entry_id = store.read_with(cx, |store, _| {
8447 let [elicitation] = store.elicitations() else {
8448 panic!(
8449 "expected one request-scoped elicitation, got {:?}",
8450 store.elicitations()
8451 );
8452 };
8453 elicitation.id.clone()
8454 });
8455
8456 store.update(cx, |store, cx| {
8457 store.complete_url_elicitation(&url_elicitation_id, cx);
8458 });
8459
8460 assert!(matches!(
8461 response_task.await.action,
8462 acp::ElicitationAction::Accept(_)
8463 ));
8464 store.update(cx, |store, cx| {
8465 store.respond_to_elicitation(
8466 &entry_id,
8467 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
8468 cx,
8469 );
8470 });
8471 store.read_with(cx, |store, _| {
8472 let Some((_, elicitation)) = store.elicitation(&entry_id) else {
8473 panic!("missing elicitation entry");
8474 };
8475 assert!(matches!(elicitation.status, ElicitationStatus::Completed));
8476 });
8477 }
8478
8479 #[gpui::test]
8480 async fn test_request_url_elicitation_store_cancel_all_cancels_accepted_url(
8481 cx: &mut TestAppContext,
8482 ) {
8483 init_test(cx);
8484 enable_acp_beta(cx);
8485 let store = cx.update(|cx| cx.new(|_| ElicitationStore::default()));
8486 let url_elicitation_id = acp::ElicitationId::new("url-1");
8487
8488 let response_task = store.update(cx, |store, cx| {
8489 store
8490 .request_elicitation(
8491 acp::CreateElicitationRequest::new(
8492 acp::ElicitationUrlMode::new(
8493 acp::ElicitationRequestScope::new(acp::RequestId::Number(1)),
8494 url_elicitation_id.clone(),
8495 "https://example.com/complete",
8496 ),
8497 "Complete this in the browser",
8498 ),
8499 cx,
8500 )
8501 .unwrap()
8502 });
8503
8504 let entry_id = store.read_with(cx, |store, _| {
8505 let [elicitation] = store.elicitations() else {
8506 panic!(
8507 "expected one elicitation entry, got {:?}",
8508 store.elicitations()
8509 );
8510 };
8511 elicitation.id.clone()
8512 });
8513
8514 store.update(cx, |store, cx| {
8515 store.respond_to_elicitation(
8516 &entry_id,
8517 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
8518 acp::ElicitationAcceptAction::new(),
8519 )),
8520 cx,
8521 );
8522 });
8523 assert!(matches!(
8524 response_task.await.action,
8525 acp::ElicitationAction::Accept(_)
8526 ));
8527 store.update(cx, |store, cx| {
8528 store.cancel_all(cx);
8529 });
8530 store.read_with(cx, |store, _| {
8531 let Some((_, elicitation)) = store.elicitation(&entry_id) else {
8532 panic!("missing elicitation entry");
8533 };
8534 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8535 });
8536
8537 store.update(cx, |store, cx| {
8538 store.complete_url_elicitation(&url_elicitation_id, cx);
8539 });
8540 store.read_with(cx, |store, _| {
8541 let Some((_, elicitation)) = store.elicitation(&entry_id) else {
8542 panic!("missing elicitation entry");
8543 };
8544 assert!(matches!(elicitation.status, ElicitationStatus::Canceled));
8545 });
8546 }
8547
8548 #[gpui::test]
8549 async fn test_cancel_pending_elicitations_preserves_responded_statuses(
8550 cx: &mut TestAppContext,
8551 ) {
8552 init_test(cx);
8553 enable_acp_beta(cx);
8554 let thread = new_test_thread(cx).await;
8555 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
8556
8557 let response_task = thread.update(cx, |thread, cx| {
8558 thread
8559 .request_elicitation(
8560 acp::CreateElicitationRequest::new(
8561 acp::ElicitationFormMode::new(
8562 acp::ElicitationSessionScope::new(session_id),
8563 acp::ElicitationSchema::new().string("name", true),
8564 ),
8565 "Provide a name",
8566 ),
8567 cx,
8568 )
8569 .unwrap()
8570 });
8571
8572 let elicitation_id = thread.read_with(cx, |thread, _| {
8573 let (elicitation_id, _) = only_thread_elicitation(thread);
8574 elicitation_id
8575 });
8576
8577 thread.update(cx, |thread, cx| {
8578 thread.respond_to_elicitation(
8579 &elicitation_id,
8580 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
8581 cx,
8582 );
8583 thread.cancel(cx).detach();
8584 });
8585
8586 assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
8587 thread.read_with(cx, |thread, _| {
8588 let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
8589 panic!("missing elicitation entry");
8590 };
8591 assert!(matches!(elicitation.status, ElicitationStatus::Declined));
8592 });
8593 }
8594
8595 #[gpui::test]
8596 async fn test_session_elicitation_ignores_duplicate_response(cx: &mut TestAppContext) {
8597 init_test(cx);
8598 enable_acp_beta(cx);
8599 let thread = new_test_thread(cx).await;
8600 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
8601
8602 let response_task = thread.update(cx, |thread, cx| {
8603 thread
8604 .request_elicitation(
8605 acp::CreateElicitationRequest::new(
8606 acp::ElicitationFormMode::new(
8607 acp::ElicitationSessionScope::new(session_id),
8608 acp::ElicitationSchema::new().string("name", true),
8609 ),
8610 "Provide a name",
8611 ),
8612 cx,
8613 )
8614 .unwrap()
8615 });
8616
8617 let elicitation_id = thread.read_with(cx, |thread, _| {
8618 let (elicitation_id, _) = only_thread_elicitation(thread);
8619 elicitation_id
8620 });
8621
8622 thread.update(cx, |thread, cx| {
8623 thread.respond_to_elicitation(
8624 &elicitation_id,
8625 acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
8626 cx,
8627 );
8628 thread.respond_to_elicitation(
8629 &elicitation_id,
8630 acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept(
8631 acp::ElicitationAcceptAction::new(),
8632 )),
8633 cx,
8634 );
8635 });
8636
8637 assert_eq!(response_task.await.action, acp::ElicitationAction::Decline);
8638 thread.read_with(cx, |thread, _| {
8639 let Some((_, elicitation)) = thread.elicitation(&elicitation_id) else {
8640 panic!("missing elicitation entry");
8641 };
8642 assert!(matches!(elicitation.status, ElicitationStatus::Declined));
8643 });
8644 }
8645
8646 #[gpui::test]
8647 async fn test_url_elicitation_rejects_invalid_url(cx: &mut TestAppContext) {
8648 init_test(cx);
8649 enable_acp_beta(cx);
8650 let thread = new_test_thread(cx).await;
8651 let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
8652
8653 let result = thread.update(cx, |thread, cx| {
8654 thread.request_elicitation(
8655 acp::CreateElicitationRequest::new(
8656 acp::ElicitationUrlMode::new(
8657 acp::ElicitationSessionScope::new(session_id),
8658 "url-1",
8659 "not a url",
8660 ),
8661 "Complete this in the browser",
8662 ),
8663 cx,
8664 )
8665 });
8666
8667 assert!(result.is_err());
8668 thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty()));
8669 }
8670
8671 async fn run_until_first_tool_call(
8672 thread: &Entity<AcpThread>,
8673 cx: &mut TestAppContext,
8674 ) -> usize {
8675 let (mut tx, mut rx) = mpsc::channel::<usize>(1);
8676
8677 let subscription = cx.update(|cx| {
8678 cx.subscribe(thread, move |thread, _, cx| {
8679 for (ix, entry) in thread.read(cx).entries.iter().enumerate() {
8680 if matches!(entry, AgentThreadEntry::ToolCall(_)) {
8681 return tx.try_send(ix).unwrap();
8682 }
8683 }
8684 })
8685 });
8686
8687 select! {
8688 _ = futures::FutureExt::fuse(cx.background_executor.timer(Duration::from_secs(10))) => {
8689 panic!("Timeout waiting for tool call")
8690 }
8691 ix = rx.next().fuse() => {
8692 drop(subscription);
8693 ix.unwrap()
8694 }
8695 }
8696 }
8697
8698 #[derive(Clone, Default)]
8699 struct FakeAgentConnection {
8700 auth_methods: Vec<acp::AuthMethod>,
8701 supports_truncate: bool,
8702 sessions: Arc<parking_lot::Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
8703 set_title_calls: Rc<RefCell<Vec<SharedString>>>,
8704 on_user_message: Option<
8705 Rc<
8706 dyn Fn(
8707 acp::PromptRequest,
8708 WeakEntity<AcpThread>,
8709 AsyncApp,
8710 ) -> LocalBoxFuture<'static, Result<acp::PromptResponse>>
8711 + 'static,
8712 >,
8713 >,
8714 }
8715
8716 impl FakeAgentConnection {
8717 fn new() -> Self {
8718 Self {
8719 auth_methods: Vec::new(),
8720 supports_truncate: true,
8721 on_user_message: None,
8722 sessions: Arc::default(),
8723 set_title_calls: Default::default(),
8724 }
8725 }
8726
8727 fn without_truncate_support(mut self) -> Self {
8728 self.supports_truncate = false;
8729 self
8730 }
8731
8732 #[expect(unused)]
8733 fn with_auth_methods(mut self, auth_methods: Vec<acp::AuthMethod>) -> Self {
8734 self.auth_methods = auth_methods;
8735 self
8736 }
8737
8738 fn on_user_message(
8739 mut self,
8740 handler: impl Fn(
8741 acp::PromptRequest,
8742 WeakEntity<AcpThread>,
8743 AsyncApp,
8744 ) -> LocalBoxFuture<'static, Result<acp::PromptResponse>>
8745 + 'static,
8746 ) -> Self {
8747 self.on_user_message.replace(Rc::new(handler));
8748 self
8749 }
8750 }
8751
8752 impl AgentConnection for FakeAgentConnection {
8753 fn agent_id(&self) -> AgentId {
8754 AgentId::new("fake")
8755 }
8756
8757 fn telemetry_id(&self) -> SharedString {
8758 "fake".into()
8759 }
8760
8761 fn auth_methods(&self) -> &[acp::AuthMethod] {
8762 &self.auth_methods
8763 }
8764
8765 fn new_session(
8766 self: Rc<Self>,
8767 project: Entity<Project>,
8768 work_dirs: PathList,
8769 cx: &mut App,
8770 ) -> Task<gpui::Result<Entity<AcpThread>>> {
8771 let session_id = acp::SessionId::new(
8772 rand::rng()
8773 .sample_iter(&distr::Alphanumeric)
8774 .take(7)
8775 .map(char::from)
8776 .collect::<String>(),
8777 );
8778 let action_log = cx.new(|_| ActionLog::new(project.clone()));
8779 let thread = cx.new(|cx| {
8780 AcpThread::new(
8781 None,
8782 None,
8783 Some(work_dirs),
8784 self.clone(),
8785 project,
8786 action_log,
8787 session_id.clone(),
8788 watch::Receiver::constant(
8789 acp::PromptCapabilities::new()
8790 .image(true)
8791 .audio(true)
8792 .embedded_context(true),
8793 ),
8794 cx,
8795 )
8796 });
8797 self.sessions.lock().insert(session_id, thread.downgrade());
8798 Task::ready(Ok(thread))
8799 }
8800
8801 fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
8802 if self.auth_methods().iter().any(|m| m.id() == &method) {
8803 Task::ready(Ok(()))
8804 } else {
8805 Task::ready(Err(anyhow!("Invalid Auth Method")))
8806 }
8807 }
8808
8809 fn prompt(
8810 &self,
8811 params: acp::PromptRequest,
8812 cx: &mut App,
8813 ) -> Task<gpui::Result<acp::PromptResponse>> {
8814 let sessions = self.sessions.lock();
8815 let thread = sessions.get(¶ms.session_id).unwrap();
8816 if let Some(handler) = &self.on_user_message {
8817 let handler = handler.clone();
8818 let thread = thread.clone();
8819 cx.spawn(async move |cx| handler(params, thread, cx.clone()).await)
8820 } else {
8821 Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
8822 }
8823 }
8824
8825 fn client_user_message_ids(
8826 &self,
8827 _cx: &App,
8828 ) -> Option<Rc<dyn AgentSessionClientUserMessageIds>> {
8829 self.supports_truncate.then(|| {
8830 Rc::new(FakeAgentSessionClientUserMessageIds {
8831 connection: self.clone(),
8832 }) as Rc<dyn AgentSessionClientUserMessageIds>
8833 })
8834 }
8835
8836 fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
8837
8838 fn truncate(
8839 &self,
8840 session_id: &acp::SessionId,
8841 _cx: &App,
8842 ) -> Option<Rc<dyn AgentSessionTruncate>> {
8843 self.supports_truncate.then(|| {
8844 Rc::new(FakeAgentSessionEditor {
8845 _session_id: session_id.clone(),
8846 }) as Rc<dyn AgentSessionTruncate>
8847 })
8848 }
8849
8850 fn set_title(
8851 &self,
8852 _session_id: &acp::SessionId,
8853 _cx: &App,
8854 ) -> Option<Rc<dyn AgentSessionSetTitle>> {
8855 Some(Rc::new(FakeAgentSessionSetTitle {
8856 calls: self.set_title_calls.clone(),
8857 }))
8858 }
8859
8860 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
8861 self
8862 }
8863 }
8864
8865 struct FakeAgentSessionSetTitle {
8866 calls: Rc<RefCell<Vec<SharedString>>>,
8867 }
8868
8869 impl AgentSessionSetTitle for FakeAgentSessionSetTitle {
8870 fn run(&self, title: SharedString, _cx: &mut App) -> Task<Result<()>> {
8871 self.calls.borrow_mut().push(title);
8872 Task::ready(Ok(()))
8873 }
8874 }
8875
8876 struct FakeAgentSessionEditor {
8877 _session_id: acp::SessionId,
8878 }
8879
8880 impl AgentSessionTruncate for FakeAgentSessionEditor {
8881 fn run(
8882 &self,
8883 _client_user_message_id: ClientUserMessageId,
8884 _cx: &mut App,
8885 ) -> Task<Result<()>> {
8886 Task::ready(Ok(()))
8887 }
8888 }
8889
8890 struct FakeAgentSessionClientUserMessageIds {
8891 connection: FakeAgentConnection,
8892 }
8893
8894 impl AgentSessionClientUserMessageIds for FakeAgentSessionClientUserMessageIds {
8895 fn prompt(
8896 &self,
8897 _client_user_message_id: ClientUserMessageId,
8898 params: acp::PromptRequest,
8899 cx: &mut App,
8900 ) -> Task<Result<acp::PromptResponse>> {
8901 self.connection.prompt(params, cx)
8902 }
8903 }
8904
8905 #[gpui::test]
8906 async fn test_tool_call_not_found_creates_failed_entry(cx: &mut TestAppContext) {
8907 init_test(cx);
8908
8909 let fs = FakeFs::new(cx.executor());
8910 let project = Project::test(fs, [], cx).await;
8911 let connection = Rc::new(FakeAgentConnection::new());
8912 let thread = cx
8913 .update(|cx| {
8914 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
8915 })
8916 .await
8917 .unwrap();
8918
8919 // Try to update a tool call that doesn't exist
8920 let nonexistent_id = acp::ToolCallId::new("nonexistent-tool-call");
8921 thread.update(cx, |thread, cx| {
8922 let result = thread.handle_session_update(
8923 acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
8924 nonexistent_id.clone(),
8925 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
8926 )),
8927 cx,
8928 );
8929
8930 // The update should succeed (not return an error)
8931 assert!(result.is_ok());
8932
8933 // There should now be exactly one entry in the thread
8934 assert_eq!(thread.entries.len(), 1);
8935
8936 // The entry should be a failed tool call
8937 if let AgentThreadEntry::ToolCall(tool_call) = &thread.entries[0] {
8938 assert_eq!(tool_call.id, nonexistent_id);
8939 assert!(matches!(tool_call.status, ToolCallStatus::Failed));
8940 assert_eq!(tool_call.kind, acp::ToolKind::Fetch);
8941
8942 // Check that the content contains the error message
8943 assert_eq!(tool_call.content.len(), 1);
8944 if let ToolCallContent::ContentBlock(content_block) = &tool_call.content[0] {
8945 match content_block {
8946 ContentBlock::Markdown { markdown } => {
8947 let markdown_text = markdown.read(cx).source();
8948 assert!(markdown_text.contains("Tool call not found"));
8949 }
8950 ContentBlock::Empty => panic!("Expected markdown content, got empty"),
8951 ContentBlock::ResourceLink { .. } => {
8952 panic!("Expected markdown content, got resource link")
8953 }
8954 ContentBlock::EmbeddedResource { .. } => {
8955 panic!("Expected markdown content, got embedded resource")
8956 }
8957 ContentBlock::Image { .. } => {
8958 panic!("Expected markdown content, got image")
8959 }
8960 }
8961 } else {
8962 panic!("Expected ContentBlock, got: {:?}", tool_call.content[0]);
8963 }
8964 } else {
8965 panic!("Expected ToolCall entry, got: {:?}", thread.entries[0]);
8966 }
8967 });
8968 }
8969
8970 /// Tests that restoring a checkpoint properly cleans up terminals that were
8971 /// created after that checkpoint, and cancels any in-progress generation.
8972 ///
8973 /// Reproduces issue #35142: When a checkpoint is restored, any terminal processes
8974 /// that were started after that checkpoint should be terminated, and any in-progress
8975 /// AI generation should be canceled.
8976 #[gpui::test]
8977 async fn test_restore_checkpoint_kills_terminal(cx: &mut TestAppContext) {
8978 init_test(cx);
8979
8980 let fs = FakeFs::new(cx.executor());
8981 let project = Project::test(fs, [], cx).await;
8982 let connection = Rc::new(FakeAgentConnection::new());
8983 let thread = cx
8984 .update(|cx| {
8985 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
8986 })
8987 .await
8988 .unwrap();
8989
8990 // Send first user message to create a checkpoint
8991 cx.update(|cx| {
8992 thread.update(cx, |thread, cx| {
8993 thread.send(vec!["first message".into()], cx)
8994 })
8995 })
8996 .await
8997 .unwrap();
8998
8999 // Send second message (creates another checkpoint) - we'll restore to this one
9000 cx.update(|cx| {
9001 thread.update(cx, |thread, cx| {
9002 thread.send(vec!["second message".into()], cx)
9003 })
9004 })
9005 .await
9006 .unwrap();
9007
9008 // Create 2 terminals BEFORE the checkpoint that have completed running
9009 let terminal_id_1 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
9010 let mock_terminal_1 = cx.new(|cx| {
9011 let builder = ::terminal::TerminalBuilder::new_display_only(
9012 ::terminal::terminal_settings::CursorShape::default(),
9013 ::terminal::terminal_settings::AlternateScroll::On,
9014 None,
9015 0,
9016 cx.background_executor(),
9017 PathStyle::local(),
9018 );
9019 builder.subscribe(cx)
9020 });
9021
9022 thread.update(cx, |thread, cx| {
9023 thread.on_terminal_provider_event(
9024 TerminalProviderEvent::Created {
9025 terminal_id: terminal_id_1.clone(),
9026 label: "echo 'first'".to_string(),
9027 cwd: Some(PathBuf::from("/test")),
9028 output_byte_limit: None,
9029 terminal: mock_terminal_1.clone(),
9030 },
9031 cx,
9032 );
9033 });
9034
9035 thread.update(cx, |thread, cx| {
9036 thread.on_terminal_provider_event(
9037 TerminalProviderEvent::Output {
9038 terminal_id: terminal_id_1.clone(),
9039 data: b"first\n".to_vec(),
9040 },
9041 cx,
9042 );
9043 });
9044
9045 thread.update(cx, |thread, cx| {
9046 thread.on_terminal_provider_event(
9047 TerminalProviderEvent::Exit {
9048 terminal_id: terminal_id_1.clone(),
9049 status: acp::TerminalExitStatus::new().exit_code(0),
9050 },
9051 cx,
9052 );
9053 });
9054
9055 let terminal_id_2 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
9056 let mock_terminal_2 = cx.new(|cx| {
9057 let builder = ::terminal::TerminalBuilder::new_display_only(
9058 ::terminal::terminal_settings::CursorShape::default(),
9059 ::terminal::terminal_settings::AlternateScroll::On,
9060 None,
9061 0,
9062 cx.background_executor(),
9063 PathStyle::local(),
9064 );
9065 builder.subscribe(cx)
9066 });
9067
9068 thread.update(cx, |thread, cx| {
9069 thread.on_terminal_provider_event(
9070 TerminalProviderEvent::Created {
9071 terminal_id: terminal_id_2.clone(),
9072 label: "echo 'second'".to_string(),
9073 cwd: Some(PathBuf::from("/test")),
9074 output_byte_limit: None,
9075 terminal: mock_terminal_2.clone(),
9076 },
9077 cx,
9078 );
9079 });
9080
9081 thread.update(cx, |thread, cx| {
9082 thread.on_terminal_provider_event(
9083 TerminalProviderEvent::Output {
9084 terminal_id: terminal_id_2.clone(),
9085 data: b"second\n".to_vec(),
9086 },
9087 cx,
9088 );
9089 });
9090
9091 thread.update(cx, |thread, cx| {
9092 thread.on_terminal_provider_event(
9093 TerminalProviderEvent::Exit {
9094 terminal_id: terminal_id_2.clone(),
9095 status: acp::TerminalExitStatus::new().exit_code(0),
9096 },
9097 cx,
9098 );
9099 });
9100
9101 // Get the second message ID to restore to
9102 let second_message_id = thread.read_with(cx, |thread, _| {
9103 // At this point we have:
9104 // - Index 0: First user message (with checkpoint)
9105 // - Index 1: Second user message (with checkpoint)
9106 // No assistant responses because FakeAgentConnection just returns EndTurn
9107 let AgentThreadEntry::UserMessage(message) = &thread.entries[1] else {
9108 panic!("expected user message at index 1");
9109 };
9110 message.client_id.clone().unwrap()
9111 });
9112
9113 // Create a terminal AFTER the checkpoint we'll restore to.
9114 // This simulates the AI agent starting a long-running terminal command.
9115 let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string());
9116 let mock_terminal = cx.new(|cx| {
9117 let builder = ::terminal::TerminalBuilder::new_display_only(
9118 ::terminal::terminal_settings::CursorShape::default(),
9119 ::terminal::terminal_settings::AlternateScroll::On,
9120 None,
9121 0,
9122 cx.background_executor(),
9123 PathStyle::local(),
9124 );
9125 builder.subscribe(cx)
9126 });
9127
9128 // Register the terminal as created
9129 thread.update(cx, |thread, cx| {
9130 thread.on_terminal_provider_event(
9131 TerminalProviderEvent::Created {
9132 terminal_id: terminal_id.clone(),
9133 label: "sleep 1000".to_string(),
9134 cwd: Some(PathBuf::from("/test")),
9135 output_byte_limit: None,
9136 terminal: mock_terminal.clone(),
9137 },
9138 cx,
9139 );
9140 });
9141
9142 // Simulate the terminal producing output (still running)
9143 thread.update(cx, |thread, cx| {
9144 thread.on_terminal_provider_event(
9145 TerminalProviderEvent::Output {
9146 terminal_id: terminal_id.clone(),
9147 data: b"terminal is running...\n".to_vec(),
9148 },
9149 cx,
9150 );
9151 });
9152
9153 // Create a tool call entry that references this terminal
9154 // This represents the agent requesting a terminal command
9155 thread.update(cx, |thread, cx| {
9156 thread
9157 .handle_session_update(
9158 acp::SessionUpdate::ToolCall(
9159 acp::ToolCall::new("terminal-tool-1", "Running command")
9160 .kind(acp::ToolKind::Execute)
9161 .status(acp::ToolCallStatus::InProgress)
9162 .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new(
9163 terminal_id.clone(),
9164 ))])
9165 .raw_input(serde_json::json!({"command": "sleep 1000", "cd": "/test"})),
9166 ),
9167 cx,
9168 )
9169 .unwrap();
9170 });
9171
9172 // Verify terminal exists and is in the thread
9173 let terminal_exists_before =
9174 thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id));
9175 assert!(
9176 terminal_exists_before,
9177 "Terminal should exist before checkpoint restore"
9178 );
9179
9180 // Verify the terminal's underlying task is still running (not completed)
9181 let terminal_running_before = thread.read_with(cx, |thread, _cx| {
9182 let terminal_entity = thread.terminals.get(&terminal_id).unwrap();
9183 terminal_entity.read_with(cx, |term, _cx| {
9184 term.output().is_none() // output is None means it's still running
9185 })
9186 });
9187 assert!(
9188 terminal_running_before,
9189 "Terminal should be running before checkpoint restore"
9190 );
9191
9192 // Verify we have the expected entries before restore
9193 let entry_count_before = thread.read_with(cx, |thread, _| thread.entries.len());
9194 assert!(
9195 entry_count_before > 1,
9196 "Should have multiple entries before restore"
9197 );
9198
9199 // Restore the checkpoint to the second message.
9200 // This should:
9201 // 1. Cancel any in-progress generation (via the cancel() call)
9202 // 2. Remove the terminal that was created after that point
9203 thread
9204 .update(cx, |thread, cx| {
9205 thread.restore_checkpoint(second_message_id, cx)
9206 })
9207 .await
9208 .unwrap();
9209
9210 // Verify that no send_task is in progress after restore
9211 // (cancel() clears the send_task)
9212 let has_send_task_after = thread.read_with(cx, |thread, _| thread.running_turn.is_some());
9213 assert!(
9214 !has_send_task_after,
9215 "Should not have a send_task after restore (cancel should have cleared it)"
9216 );
9217
9218 // Verify the entries were truncated (restoring to index 1 truncates at 1, keeping only index 0)
9219 let entry_count = thread.read_with(cx, |thread, _| thread.entries.len());
9220 assert_eq!(
9221 entry_count, 1,
9222 "Should have 1 entry after restore (only the first user message)"
9223 );
9224
9225 // Verify the 2 completed terminals from before the checkpoint still exist
9226 let terminal_1_exists = thread.read_with(cx, |thread, _| {
9227 thread.terminals.contains_key(&terminal_id_1)
9228 });
9229 assert!(
9230 terminal_1_exists,
9231 "Terminal 1 (from before checkpoint) should still exist"
9232 );
9233
9234 let terminal_2_exists = thread.read_with(cx, |thread, _| {
9235 thread.terminals.contains_key(&terminal_id_2)
9236 });
9237 assert!(
9238 terminal_2_exists,
9239 "Terminal 2 (from before checkpoint) should still exist"
9240 );
9241
9242 // Verify they're still in completed state
9243 let terminal_1_completed = thread.read_with(cx, |thread, _cx| {
9244 let terminal_entity = thread.terminals.get(&terminal_id_1).unwrap();
9245 terminal_entity.read_with(cx, |term, _cx| term.output().is_some())
9246 });
9247 assert!(terminal_1_completed, "Terminal 1 should still be completed");
9248
9249 let terminal_2_completed = thread.read_with(cx, |thread, _cx| {
9250 let terminal_entity = thread.terminals.get(&terminal_id_2).unwrap();
9251 terminal_entity.read_with(cx, |term, _cx| term.output().is_some())
9252 });
9253 assert!(terminal_2_completed, "Terminal 2 should still be completed");
9254
9255 // Verify the running terminal (created after checkpoint) was removed
9256 let terminal_3_exists =
9257 thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id));
9258 assert!(
9259 !terminal_3_exists,
9260 "Terminal 3 (created after checkpoint) should have been removed"
9261 );
9262
9263 // Verify total count is 2 (the two from before the checkpoint)
9264 let terminal_count = thread.read_with(cx, |thread, _| thread.terminals.len());
9265 assert_eq!(
9266 terminal_count, 2,
9267 "Should have exactly 2 terminals (the completed ones from before checkpoint)"
9268 );
9269 }
9270
9271 /// Tests that update_last_checkpoint correctly updates the original message's checkpoint
9272 /// even when a new user message is added while the async checkpoint comparison is in progress.
9273 ///
9274 /// This is a regression test for a bug where update_last_checkpoint would fail with
9275 /// "no checkpoint" if a new user message (without a checkpoint) was added between when
9276 /// update_last_checkpoint started and when its async closure ran.
9277 #[gpui::test]
9278 async fn test_update_last_checkpoint_with_new_message_added(cx: &mut TestAppContext) {
9279 init_test(cx);
9280
9281 let fs = FakeFs::new(cx.executor());
9282 fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"}))
9283 .await;
9284 let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await;
9285
9286 let handler_done = Arc::new(AtomicBool::new(false));
9287 let handler_done_clone = handler_done.clone();
9288 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
9289 move |_, _thread, _cx| {
9290 handler_done_clone.store(true, SeqCst);
9291 async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }.boxed_local()
9292 },
9293 ));
9294
9295 let thread = cx
9296 .update(|cx| {
9297 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9298 })
9299 .await
9300 .unwrap();
9301
9302 let send_future = thread.update(cx, |thread, cx| thread.send_raw("First message", cx));
9303 let send_task = cx.background_executor.spawn(send_future);
9304
9305 // Tick until handler completes, then a few more to let update_last_checkpoint start
9306 while !handler_done.load(SeqCst) {
9307 cx.executor().tick();
9308 }
9309 for _ in 0..5 {
9310 cx.executor().tick();
9311 }
9312
9313 thread.update(cx, |thread, cx| {
9314 thread.push_entry(
9315 AgentThreadEntry::UserMessage(UserMessage {
9316 protocol_id: None,
9317 client_id: Some(ClientUserMessageId::new()),
9318 is_optimistic: true,
9319 content: ContentBlock::Empty,
9320 chunks: vec!["Injected message (no checkpoint)".into()],
9321 checkpoint: None,
9322 indented: false,
9323 }),
9324 cx,
9325 );
9326 });
9327
9328 cx.run_until_parked();
9329 let result = send_task.await;
9330
9331 assert!(
9332 result.is_ok(),
9333 "send should succeed even when new message added during update_last_checkpoint: {:?}",
9334 result.err()
9335 );
9336 }
9337
9338 /// This is a regression test for a bug where update_last_checkpoint would
9339 /// swallow a checkpoint comparison error and hide an already-visible
9340 /// "Restore checkpoint" button without logging anything.
9341 #[gpui::test]
9342 async fn test_update_last_checkpoint_compare_error_keeps_checkpoint_visible(
9343 cx: &mut TestAppContext,
9344 ) {
9345 init_test(cx);
9346
9347 let fs = FakeFs::new(cx.executor());
9348 fs.insert_tree(path!("/test"), json!({".git": {}, "file.txt": "content"}))
9349 .await;
9350 let project = Project::test(fs.clone(), [Path::new(path!("/test"))], cx).await;
9351
9352 // The handler waits for this signal so the repository can be swapped
9353 // out while the turn is still running.
9354 let (complete_tx, complete_rx) = futures::channel::oneshot::channel::<()>();
9355 let complete_rx = RefCell::new(Some(complete_rx));
9356 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
9357 move |_, _thread, _cx| {
9358 let complete_rx = complete_rx.borrow_mut().take();
9359 async move {
9360 if let Some(rx) = complete_rx {
9361 rx.await.ok();
9362 }
9363 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
9364 }
9365 .boxed_local()
9366 },
9367 ));
9368
9369 let thread = cx
9370 .update(|cx| {
9371 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9372 })
9373 .await
9374 .unwrap();
9375
9376 let send_future = thread.update(cx, |thread, cx| thread.send_raw("message", cx));
9377 let send_task = cx.background_executor.spawn(send_future);
9378 cx.run_until_parked();
9379
9380 // Show the checkpoint, as update_last_checkpoint_if_changed does when
9381 // files change during the turn.
9382 thread.update(cx, |thread, _| {
9383 let (_, message) = thread.last_user_message().unwrap();
9384 message.checkpoint.as_mut().unwrap().show = true;
9385 });
9386
9387 // Recreate `.git` so the git store reopens the repository. The fresh
9388 // fake repository doesn't contain the checkpoint recorded at send
9389 // time, so the end-of-turn comparison fails.
9390 fs.remove_dir(
9391 Path::new(path!("/test/.git")),
9392 RemoveOptions {
9393 recursive: true,
9394 ignore_if_not_exists: false,
9395 },
9396 )
9397 .await
9398 .unwrap();
9399 cx.run_until_parked();
9400 fs.create_dir(Path::new(path!("/test/.git"))).await.unwrap();
9401 cx.run_until_parked();
9402
9403 complete_tx.send(()).unwrap();
9404 send_task.await.unwrap();
9405 cx.run_until_parked();
9406
9407 thread.update(cx, |thread, _| {
9408 let (_, message) = thread.last_user_message().unwrap();
9409 assert!(
9410 message.checkpoint.as_ref().unwrap().show,
9411 "a checkpoint comparison failure must not hide the restore checkpoint button"
9412 );
9413 });
9414 }
9415
9416 /// Tests that when a follow-up message is sent during generation,
9417 /// the first turn completing does NOT clear `running_turn` because
9418 /// it now belongs to the second turn.
9419 #[gpui::test]
9420 async fn test_follow_up_message_during_generation_does_not_clear_turn(cx: &mut TestAppContext) {
9421 init_test(cx);
9422
9423 let fs = FakeFs::new(cx.executor());
9424 let project = Project::test(fs, [], cx).await;
9425
9426 // First handler waits for this signal before completing
9427 let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>();
9428 let first_complete_rx = RefCell::new(Some(first_complete_rx));
9429
9430 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
9431 move |params, _thread, _cx| {
9432 let first_complete_rx = first_complete_rx.borrow_mut().take();
9433 let is_first = params
9434 .prompt
9435 .iter()
9436 .any(|c| matches!(c, acp::ContentBlock::Text(t) if t.text.contains("first")));
9437
9438 async move {
9439 if is_first {
9440 // First handler waits until signaled
9441 if let Some(rx) = first_complete_rx {
9442 rx.await.ok();
9443 }
9444 }
9445 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
9446 }
9447 .boxed_local()
9448 }
9449 }));
9450
9451 let thread = cx
9452 .update(|cx| {
9453 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9454 })
9455 .await
9456 .unwrap();
9457
9458 // Send first message (turn_id=1) - handler will block
9459 let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx));
9460 assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 1);
9461
9462 // Send second message (turn_id=2) while first is still blocked
9463 // This calls cancel() which takes turn 1's running_turn and sets turn 2's
9464 let second_request = thread.update(cx, |thread, cx| thread.send_raw("second", cx));
9465 assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 2);
9466
9467 let running_turn_after_second_send =
9468 thread.read_with(cx, |thread, _| thread.running_turn.as_ref().map(|t| t.id));
9469 assert_eq!(
9470 running_turn_after_second_send,
9471 Some(2),
9472 "running_turn should be set to turn 2 after sending second message"
9473 );
9474
9475 // Now signal first handler to complete
9476 first_complete_tx.send(()).ok();
9477
9478 // First request completes - should NOT clear running_turn
9479 // because running_turn now belongs to turn 2
9480 first_request.await.unwrap();
9481
9482 let running_turn_after_first =
9483 thread.read_with(cx, |thread, _| thread.running_turn.as_ref().map(|t| t.id));
9484 assert_eq!(
9485 running_turn_after_first,
9486 Some(2),
9487 "first turn completing should not clear running_turn (belongs to turn 2)"
9488 );
9489
9490 // Second request completes - SHOULD clear running_turn
9491 second_request.await.unwrap();
9492
9493 let running_turn_after_second =
9494 thread.read_with(cx, |thread, _| thread.running_turn.is_some());
9495 assert!(
9496 !running_turn_after_second,
9497 "second turn completing should clear running_turn"
9498 );
9499 }
9500
9501 #[gpui::test]
9502 async fn test_stale_cancelled_response_does_not_cancel_current_compaction(
9503 cx: &mut TestAppContext,
9504 ) {
9505 init_test(cx);
9506
9507 let fs = FakeFs::new(cx.executor());
9508 let project = Project::test(fs, [], cx).await;
9509
9510 let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>();
9511 let first_complete_rx = RefCell::new(Some(first_complete_rx));
9512 let compaction_id = ContextCompactionId("test-compaction".into());
9513
9514 let connection = Rc::new(FakeAgentConnection::new().on_user_message({
9515 let compaction_id = compaction_id.clone();
9516 move |params, thread, mut cx| {
9517 let first_complete_rx = first_complete_rx.borrow_mut().take();
9518 let is_first = params.prompt.iter().any(|content| {
9519 matches!(content, acp::ContentBlock::Text(text) if text.text.contains("first"))
9520 });
9521 let compaction_id = compaction_id.clone();
9522
9523 async move {
9524 if is_first {
9525 if let Some(rx) = first_complete_rx {
9526 rx.await
9527 .expect("first completion sender should still be alive");
9528 }
9529
9530 thread.update(&mut cx, |thread, cx| {
9531 thread.push_context_compaction(
9532 ContextCompaction {
9533 id: compaction_id,
9534 status: ContextCompactionStatus::InProgress,
9535 summary: None,
9536 },
9537 cx,
9538 );
9539 })?;
9540
9541 Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))
9542 } else {
9543 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))
9544 }
9545 }
9546 .boxed_local()
9547 }
9548 }));
9549
9550 let thread = cx
9551 .update(|cx| {
9552 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9553 })
9554 .await
9555 .unwrap();
9556
9557 let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx));
9558 assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 1);
9559
9560 let second_request = thread.update(cx, |thread, cx| thread.send_raw("second", cx));
9561 assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 2);
9562
9563 first_complete_tx
9564 .send(())
9565 .expect("first completion receiver should still be alive");
9566
9567 let response = first_request
9568 .await
9569 .expect("first request should complete")
9570 .expect("first request should have response");
9571 assert_eq!(response.stop_reason, acp::StopReason::Cancelled);
9572
9573 thread.read_with(cx, |thread, _| {
9574 let compaction = thread
9575 .entries
9576 .iter()
9577 .find_map(|entry| {
9578 let AgentThreadEntry::ContextCompaction(compaction) = entry else {
9579 return None;
9580 };
9581 (compaction.id == compaction_id).then_some(compaction)
9582 })
9583 .expect("compaction entry should exist");
9584
9585 assert_eq!(
9586 compaction.status,
9587 ContextCompactionStatus::InProgress,
9588 "a stale cancelled response from an older turn should not cancel current compaction"
9589 );
9590 });
9591
9592 second_request
9593 .await
9594 .expect("second request should complete");
9595 }
9596
9597 #[gpui::test]
9598 async fn test_send_omits_message_id_without_client_user_message_id_support(
9599 cx: &mut TestAppContext,
9600 ) {
9601 init_test(cx);
9602
9603 let fs = FakeFs::new(cx.executor());
9604 let project = Project::test(fs, [], cx).await;
9605
9606 let connection = Rc::new(FakeAgentConnection::new().without_truncate_support());
9607 let thread = cx
9608 .update(|cx| {
9609 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9610 })
9611 .await
9612 .unwrap();
9613
9614 let response = thread
9615 .update(cx, |thread, cx| thread.send_raw("test message", cx))
9616 .await;
9617
9618 assert!(response.is_ok(), "send should not fail: {response:?}");
9619 thread.read_with(cx, |thread, _| {
9620 let AgentThreadEntry::UserMessage(message) = &thread.entries[0] else {
9621 panic!("expected first entry to be a user message")
9622 };
9623 assert_eq!(message.protocol_id, None);
9624 assert_eq!(message.client_id, None);
9625 assert!(message.is_optimistic);
9626 });
9627 }
9628
9629 #[gpui::test]
9630 async fn test_send_returns_cancelled_response_and_marks_tools_as_cancelled(
9631 cx: &mut TestAppContext,
9632 ) {
9633 init_test(cx);
9634
9635 let fs = FakeFs::new(cx.executor());
9636 let project = Project::test(fs, [], cx).await;
9637
9638 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
9639 move |_params, thread, mut cx| {
9640 async move {
9641 thread
9642 .update(&mut cx, |thread, cx| {
9643 thread.handle_session_update(
9644 acp::SessionUpdate::ToolCall(
9645 acp::ToolCall::new(
9646 acp::ToolCallId::new("test-tool"),
9647 "Test Tool",
9648 )
9649 .kind(acp::ToolKind::Fetch)
9650 .status(acp::ToolCallStatus::InProgress),
9651 ),
9652 cx,
9653 )
9654 })
9655 .unwrap()
9656 .unwrap();
9657
9658 Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))
9659 }
9660 .boxed_local()
9661 },
9662 ));
9663
9664 let thread = cx
9665 .update(|cx| {
9666 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9667 })
9668 .await
9669 .unwrap();
9670
9671 let response = thread
9672 .update(cx, |thread, cx| thread.send_raw("test message", cx))
9673 .await;
9674
9675 let response = response
9676 .expect("send should succeed")
9677 .expect("should have response");
9678 assert_eq!(
9679 response.stop_reason,
9680 acp::StopReason::Cancelled,
9681 "response should have Cancelled stop_reason"
9682 );
9683
9684 thread.read_with(cx, |thread, _| {
9685 let tool_entry = thread
9686 .entries
9687 .iter()
9688 .find_map(|e| {
9689 if let AgentThreadEntry::ToolCall(call) = e {
9690 Some(call)
9691 } else {
9692 None
9693 }
9694 })
9695 .expect("should have tool call entry");
9696
9697 assert!(
9698 matches!(tool_entry.status, ToolCallStatus::Canceled),
9699 "tool should be marked as Canceled when response is Cancelled, got {:?}",
9700 tool_entry.status
9701 );
9702 });
9703 }
9704
9705 #[gpui::test]
9706 async fn test_provisional_title_replaced_by_real_title(cx: &mut TestAppContext) {
9707 init_test(cx);
9708
9709 let fs = FakeFs::new(cx.executor());
9710 let project = Project::test(fs, [], cx).await;
9711 let connection = Rc::new(FakeAgentConnection::new());
9712 let set_title_calls = connection.set_title_calls.clone();
9713
9714 let thread = cx
9715 .update(|cx| {
9716 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9717 })
9718 .await
9719 .unwrap();
9720
9721 // Initial title is the default.
9722 thread.read_with(cx, |thread, _| {
9723 assert_eq!(thread.title(), None);
9724 });
9725
9726 // Setting a provisional title updates the display title.
9727 thread.update(cx, |thread, cx| {
9728 thread.set_provisional_title("Hello, can you help…".into(), cx);
9729 });
9730 thread.read_with(cx, |thread, _| {
9731 assert_eq!(
9732 thread.title().as_ref().map(|s| s.as_str()),
9733 Some("Hello, can you help…")
9734 );
9735 });
9736
9737 // The provisional title should NOT have propagated to the connection.
9738 assert_eq!(
9739 set_title_calls.borrow().len(),
9740 0,
9741 "provisional title should not propagate to the connection"
9742 );
9743
9744 // When the real title arrives via set_title, it replaces the
9745 // provisional title and propagates to the connection.
9746 let task = thread.update(cx, |thread, cx| {
9747 thread.set_title("Helping with Rust question".into(), cx)
9748 });
9749 task.await.expect("set_title should succeed");
9750 thread.read_with(cx, |thread, _| {
9751 assert_eq!(
9752 thread.title().as_ref().map(|s| s.as_str()),
9753 Some("Helping with Rust question")
9754 );
9755 });
9756 assert_eq!(
9757 set_title_calls.borrow().as_slice(),
9758 &[SharedString::from("Helping with Rust question")],
9759 "real title should propagate to the connection"
9760 );
9761 }
9762
9763 #[gpui::test]
9764 async fn test_session_info_update_replaces_provisional_title_and_emits_event(
9765 cx: &mut TestAppContext,
9766 ) {
9767 init_test(cx);
9768
9769 let fs = FakeFs::new(cx.executor());
9770 let project = Project::test(fs, [], cx).await;
9771 let connection = Rc::new(FakeAgentConnection::new());
9772
9773 let thread = cx
9774 .update(|cx| {
9775 connection.clone().new_session(
9776 project,
9777 PathList::new(&[Path::new(path!("/test"))]),
9778 cx,
9779 )
9780 })
9781 .await
9782 .unwrap();
9783
9784 let title_updated_events = Rc::new(RefCell::new(0usize));
9785 let title_updated_events_for_subscription = title_updated_events.clone();
9786 thread.update(cx, |_thread, cx| {
9787 cx.subscribe(
9788 &thread,
9789 move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
9790 if matches!(event, AcpThreadEvent::TitleUpdated) {
9791 *title_updated_events_for_subscription.borrow_mut() += 1;
9792 }
9793 },
9794 )
9795 .detach();
9796 });
9797
9798 thread.update(cx, |thread, cx| {
9799 thread.set_provisional_title("Hello, can you help…".into(), cx);
9800 });
9801 assert_eq!(
9802 *title_updated_events.borrow(),
9803 1,
9804 "setting a provisional title should emit TitleUpdated"
9805 );
9806
9807 let result = thread.update(cx, |thread, cx| {
9808 thread.handle_session_update(
9809 acp::SessionUpdate::SessionInfoUpdate(
9810 acp::SessionInfoUpdate::new().title("Helping with Rust question"),
9811 ),
9812 cx,
9813 )
9814 });
9815 result.expect("session info update should succeed");
9816
9817 thread.read_with(cx, |thread, _| {
9818 assert_eq!(
9819 thread.title().as_ref().map(|s| s.as_str()),
9820 Some("Helping with Rust question")
9821 );
9822 assert!(
9823 !thread.has_provisional_title(),
9824 "session info title update should clear provisional title"
9825 );
9826 });
9827
9828 assert_eq!(
9829 *title_updated_events.borrow(),
9830 2,
9831 "session info title update should emit TitleUpdated"
9832 );
9833 assert!(
9834 connection.set_title_calls.borrow().is_empty(),
9835 "session info title update should not propagate back to the connection"
9836 );
9837 }
9838
9839 #[gpui::test]
9840 async fn test_usage_update_populates_token_usage_and_cost(cx: &mut TestAppContext) {
9841 init_test(cx);
9842
9843 let fs = FakeFs::new(cx.executor());
9844 let project = Project::test(fs, [], cx).await;
9845 let connection = Rc::new(FakeAgentConnection::new());
9846 let thread = cx
9847 .update(|cx| {
9848 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9849 })
9850 .await
9851 .unwrap();
9852
9853 thread.update(cx, |thread, cx| {
9854 thread
9855 .handle_session_update(
9856 acp::SessionUpdate::UsageUpdate(
9857 acp::UsageUpdate::new(5000, 10000).cost(acp::Cost::new(0.42, "USD")),
9858 ),
9859 cx,
9860 )
9861 .unwrap();
9862 });
9863
9864 thread.read_with(cx, |thread, _| {
9865 let usage = thread.token_usage().expect("token_usage should be set");
9866 assert_eq!(usage.max_tokens, 10000);
9867 assert_eq!(usage.used_tokens, 5000);
9868
9869 let cost = thread.cost().expect("cost should be set");
9870 assert!((cost.amount - 0.42).abs() < f64::EPSILON);
9871 assert_eq!(cost.currency.as_ref(), "USD");
9872 });
9873 }
9874
9875 #[gpui::test]
9876 async fn test_context_compaction_preserves_token_usage(cx: &mut TestAppContext) {
9877 init_test(cx);
9878
9879 let fs = FakeFs::new(cx.executor());
9880 let project = Project::test(fs, [], cx).await;
9881 let connection = Rc::new(FakeAgentConnection::new());
9882 let thread = cx
9883 .update(|cx| {
9884 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9885 })
9886 .await
9887 .unwrap();
9888
9889 thread.update(cx, |thread, cx| {
9890 thread
9891 .handle_session_update(
9892 acp::SessionUpdate::UsageUpdate(
9893 acp::UsageUpdate::new(5000, 10000).cost(acp::Cost::new(0.42, "USD")),
9894 ),
9895 cx,
9896 )
9897 .unwrap();
9898
9899 thread.push_context_compaction(
9900 ContextCompaction {
9901 id: ContextCompactionId("compaction-1".into()),
9902 status: ContextCompactionStatus::InProgress,
9903 summary: None,
9904 },
9905 cx,
9906 );
9907 });
9908
9909 thread.read_with(cx, |thread, _| {
9910 let usage = thread
9911 .token_usage()
9912 .expect("context compaction should not clear token usage on its own");
9913 assert_eq!(usage.used_tokens, 5000);
9914 assert_eq!(usage.max_tokens, 10000);
9915
9916 let cost = thread
9917 .cost()
9918 .expect("context compaction should not clear cost on its own");
9919 assert!((cost.amount - 0.42).abs() < f64::EPSILON);
9920 });
9921
9922 thread.update(cx, |thread, cx| {
9923 thread
9924 .handle_session_update(
9925 acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(1000, 10000)),
9926 cx,
9927 )
9928 .unwrap();
9929 });
9930
9931 thread.read_with(cx, |thread, _| {
9932 let usage = thread
9933 .token_usage()
9934 .expect("token_usage should be restored by the next usage update");
9935 assert_eq!(usage.used_tokens, 1000);
9936 assert_eq!(usage.max_tokens, 10000);
9937 });
9938 }
9939
9940 #[gpui::test]
9941 async fn test_usage_update_without_cost_preserves_existing_cost(cx: &mut TestAppContext) {
9942 init_test(cx);
9943
9944 let fs = FakeFs::new(cx.executor());
9945 let project = Project::test(fs, [], cx).await;
9946 let connection = Rc::new(FakeAgentConnection::new());
9947 let thread = cx
9948 .update(|cx| {
9949 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
9950 })
9951 .await
9952 .unwrap();
9953
9954 thread.update(cx, |thread, cx| {
9955 thread
9956 .handle_session_update(
9957 acp::SessionUpdate::UsageUpdate(
9958 acp::UsageUpdate::new(1000, 10000).cost(acp::Cost::new(0.10, "USD")),
9959 ),
9960 cx,
9961 )
9962 .unwrap();
9963
9964 thread
9965 .handle_session_update(
9966 acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(2000, 10000)),
9967 cx,
9968 )
9969 .unwrap();
9970 });
9971
9972 thread.read_with(cx, |thread, _| {
9973 let usage = thread.token_usage().expect("token_usage should be set");
9974 assert_eq!(usage.used_tokens, 2000);
9975
9976 let cost = thread.cost().expect("cost should be preserved");
9977 assert!((cost.amount - 0.10).abs() < f64::EPSILON);
9978 });
9979 }
9980
9981 #[gpui::test]
9982 async fn test_response_usage_does_not_clobber_session_usage(cx: &mut TestAppContext) {
9983 init_test(cx);
9984
9985 let fs = FakeFs::new(cx.executor());
9986 let project = Project::test(fs, [], cx).await;
9987 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
9988 move |_, thread, mut cx| {
9989 async move {
9990 thread.update(&mut cx, |thread, cx| {
9991 thread
9992 .handle_session_update(
9993 acp::SessionUpdate::UsageUpdate(
9994 acp::UsageUpdate::new(3000, 10000)
9995 .cost(acp::Cost::new(0.05, "EUR")),
9996 ),
9997 cx,
9998 )
9999 .unwrap();
10000 })?;
10001 Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)
10002 .usage(acp::Usage::new(500, 200, 300)))
10003 }
10004 .boxed_local()
10005 },
10006 ));
10007
10008 let thread = cx
10009 .update(|cx| {
10010 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
10011 })
10012 .await
10013 .unwrap();
10014
10015 thread
10016 .update(cx, |thread, cx| thread.send_raw("hello", cx))
10017 .await
10018 .unwrap();
10019
10020 thread.read_with(cx, |thread, _| {
10021 let usage = thread.token_usage().expect("token_usage should be set");
10022 assert_eq!(usage.max_tokens, 10000, "max_tokens from UsageUpdate");
10023 assert_eq!(usage.used_tokens, 3000, "used_tokens from UsageUpdate");
10024 assert_eq!(usage.input_tokens, 200, "input_tokens from response usage");
10025 assert_eq!(
10026 usage.output_tokens, 300,
10027 "output_tokens from response usage"
10028 );
10029
10030 let cost = thread.cost().expect("cost should be set");
10031 assert!((cost.amount - 0.05).abs() < f64::EPSILON);
10032 assert_eq!(cost.currency.as_ref(), "EUR");
10033 });
10034 }
10035
10036 #[gpui::test]
10037 async fn test_clearing_token_usage_also_clears_cost(cx: &mut TestAppContext) {
10038 init_test(cx);
10039
10040 let fs = FakeFs::new(cx.executor());
10041 let project = Project::test(fs, [], cx).await;
10042 let connection = Rc::new(FakeAgentConnection::new());
10043 let thread = cx
10044 .update(|cx| {
10045 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
10046 })
10047 .await
10048 .unwrap();
10049
10050 thread.update(cx, |thread, cx| {
10051 thread
10052 .handle_session_update(
10053 acp::SessionUpdate::UsageUpdate(
10054 acp::UsageUpdate::new(1000, 10000).cost(acp::Cost::new(0.25, "USD")),
10055 ),
10056 cx,
10057 )
10058 .unwrap();
10059
10060 assert!(thread.token_usage().is_some());
10061 assert!(thread.cost().is_some());
10062
10063 thread.update_token_usage(None, cx);
10064
10065 assert!(thread.token_usage().is_none());
10066 assert!(
10067 thread.cost().is_none(),
10068 "cost should be cleared when token usage is cleared"
10069 );
10070 });
10071 }
10072
10073 /// Regression test: if the inner send_task is cancelled before it can
10074 /// fire `tx.send(...)` (e.g. because the underlying future was dropped),
10075 /// the outer task observes `rx.await` returning `Err(Cancelled)` and
10076 /// must still clear `running_turn` so the panel transitions out of
10077 /// `Generating`. Without this, the agent thread is wedged in the
10078 /// loading state until Zed restarts.
10079 #[gpui::test]
10080 async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) {
10081 init_test(cx);
10082
10083 let fs = FakeFs::new(cx.executor());
10084 let project = Project::test(fs, [], cx).await;
10085
10086 // Handler hangs forever so the spawn at run_turn is parked inside
10087 // `f(this, cx).await` with `tx` still alive but unsent.
10088 let connection = Rc::new(FakeAgentConnection::new().on_user_message(
10089 |_params, _thread, _cx| {
10090 async move { futures::future::pending::<Result<acp::PromptResponse>>().await }
10091 .boxed_local()
10092 },
10093 ));
10094
10095 let thread = cx
10096 .update(|cx| {
10097 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
10098 })
10099 .await
10100 .unwrap();
10101
10102 let request = thread.update(cx, |thread, cx| thread.send_raw("hello", cx));
10103 cx.run_until_parked();
10104
10105 assert_eq!(
10106 thread.read_with(cx, |t, _| t.status()),
10107 ThreadStatus::Generating,
10108 "thread should be generating while the handler is parked"
10109 );
10110
10111 // Replace the in-flight send_task with a no-op. Dropping the original
10112 // Task cancels its inner future, which drops `tx` without ever calling
10113 // `tx.send(...)`. This mirrors the production scenario where the
10114 // send_task future is cancelled before completion.
10115 thread.update(cx, |thread, _| {
10116 thread.running_turn.as_mut().unwrap().send_task = Task::ready(());
10117 });
10118
10119 let result = request.await;
10120 assert!(
10121 matches!(result, Ok(None)),
10122 "outer task should resolve to Ok(None) on dropped tx, got {result:?}"
10123 );
10124
10125 assert_eq!(
10126 thread.read_with(cx, |t, _| t.status()),
10127 ThreadStatus::Idle,
10128 "running_turn must be cleared even when tx was dropped without send"
10129 );
10130 }
10131
10132 /// OMEGA-DELTA-0045. A host-authored note lands in the transcript, once.
10133 ///
10134 /// The source check in `crates/omega_deltas` reads the shape; this runs it.
10135 /// Both halves matter: the shape check would pass an implementation that
10136 /// keyed on the id and appended anyway, and a behavioural test alone would
10137 /// pass an implementation nothing renders.
10138 #[gpui::test]
10139 async fn test_system_note_is_appended_once_per_note_id(cx: &mut TestAppContext) {
10140 init_test(cx);
10141
10142 let fs = FakeFs::new(cx.executor());
10143 let project = Project::test(fs, [], cx).await;
10144 let connection = Rc::new(FakeAgentConnection::new());
10145 let thread = cx
10146 .update(|cx| {
10147 connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
10148 })
10149 .await
10150 .unwrap();
10151
10152 let note = |id: &str, text: &str| SystemNote {
10153 id: SystemNoteId(id.into()),
10154 text: text.into(),
10155 };
10156
10157 thread.update(cx, |thread, cx| {
10158 assert!(
10159 thread.push_system_note(
10160 note("handoff.provider.1", "Provider handoff: codex-local to claude-local"),
10161 cx,
10162 ),
10163 "the first append of a note id must land"
10164 );
10165 assert!(
10166 !thread.push_system_note(
10167 note("handoff.provider.1", "Provider handoff: rewritten"),
10168 cx,
10169 ),
10170 "a retry of the same note id must not append again, and must \
10171 not rewrite what the owner has already read"
10172 );
10173 assert!(
10174 thread.push_system_note(
10175 note("handoff.provider.2", "Provider handoff: claude-local to codex-local"),
10176 cx,
10177 ),
10178 "a different note id is a different disclosure"
10179 );
10180 });
10181
10182 thread.read_with(cx, |thread, cx| {
10183 let notes: Vec<&SystemNote> = thread
10184 .entries()
10185 .iter()
10186 .filter_map(|entry| match entry {
10187 AgentThreadEntry::SystemNote(note) => Some(note),
10188 _ => None,
10189 })
10190 .collect();
10191 assert_eq!(notes.len(), 2, "one entry per distinct note id");
10192 assert_eq!(
10193 notes[0].text.as_ref(),
10194 "Provider handoff: codex-local to claude-local",
10195 "the retry must not have rewritten the first disclosure"
10196 );
10197
10198 // The transcript the owner copies out carries it too. A note that
10199 // is on screen and absent from the export would let the silence
10200 // rc11 shipped come back the moment anyone quotes a thread.
10201 let markdown = thread.to_markdown(cx);
10202 assert!(
10203 markdown.contains("Provider handoff: codex-local to claude-local"),
10204 "the exported transcript must carry the disclosure: {markdown}"
10205 );
10206 });
10207 }
10208}
10209