返回 CodeWhale
acp_server.rs
根目录 / crates / tui / src / acp_server.rs
1 //! Minimal Agent Client Protocol stdio adapter.
2 //!
3 //! This starts from the ACP baseline: initialize, new session, prompt, and
4 //! cancel. It keeps stdout protocol-clean for editor clients and routes
5 //! prompts through the same configured provider route as one-shot CLI mode.
6 //!
7 //! `session/new` and `session/load` expose mode/model configuration. Standard
8 //! setters change only the addressed in-memory session between turns. Plan
9 //! uses the shared read-only tool authority and sandbox; changing mode is an
10 //! explicit prompt-prefix invalidation for the next turn. In-flight turns
11 //! keep their frozen prefix and return the existing busy error for setters.
12 //! Configuration is connection-local, not a new durable preference store;
13 //! legacy unscoped `selectModel` changes defaults for future sessions.
14 //!
15 //! `session/prompt` streams the provider response: each text delta is emitted
16 //! as a `session/update` agent_message_chunk as it arrives, instead of buffering
17 //! the whole turn and sending one chunk at the end. The stream is consumed
18 //! concurrently with the input reader so that a `session/cancel` for the same
19 //! session can interrupt the turn mid-stream (returning `stopReason: "cancelled"`)
20 //! instead of being queued behind it. A single writer task is preserved so
21 //! stdout stays protocol-clean.
22 //!
23 //! Each ACP session owns a [`crate::tools::ToolRegistry`] built from the same
24 //! file/search/git/patch/shell tools the CLI `exec` agent and the MCP server
25 //! adapter (`crate::mcp_server`) already use. When the model emits a tool call,
26 //! the turn driver executes it locally through that registry (no duplicate
27 //! filesystem/shell implementation), reports progress to the client as
28 //! `tool_call` / `tool_call_update` session updates, feeds the result back as
29 //! a `tool_result` content block, and re-opens the provider stream so the
30 //! model can keep going until it produces a final answer with no further tool
31 //! calls.
32
33 use std::collections::{HashMap, VecDeque};
34 use std::future::Future;
35 use std::path::PathBuf;
36 use std::sync::Arc;
37 use std::sync::atomic::{AtomicU64, Ordering};
38 use std::time::Duration;
39
40 use anyhow::{Result, anyhow};
41 use futures_util::StreamExt;
42 use serde_json::{Value, json};
43 use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader, Lines};
44 use tokio_util::sync::CancellationToken;
45
46 use crate::client::CodewhaleClient;
47 use crate::config::{ApiProvider, Config};
48 use crate::core::engine::turn_loop::run_tool_call_before_hooks;
49 use crate::core::engine::{
50 AutoReviewPlanDecision, ToolAskRuleDecision, auto_review_plan_decision_for_context,
51 exec_shell_ask_rule_decision_for_policy, file_tool_ask_rule_decision_for_policy,
52 };
53 use crate::llm_client::{LlmClient, StreamEventBox};
54 use crate::tools::spec::{ApprovalRequirement, PreparedToolCall, RichToolResult, ToolError};
55 use crate::tools::{ToolContext, ToolRegistry, ToolRegistryBuilder};
56 use crate::worker_profile::ShellPolicy;
57 use codewhale_config::AppMode;
58 use codewhale_execpolicy::ApprovalMode;
59 use codewhale_models::Role;
60 use codewhale_models::{
61 ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, SystemPrompt,
62 };
63
64 const ACP_PROTOCOL_VERSION: u64 = 1;
65
66 /// Hard cap on LLM <-> tool round-trips within a single `session/prompt`
67 /// turn. Guards against a model that never stops calling tools; each round is
68 /// one provider stream plus zero or more tool executions.
69 const MAX_ACP_TOOL_ROUNDS: usize = 50;
70
71 /// Maximum number of concurrent sessions kept in memory. When this limit is
72 /// exceeded, the oldest session with no in-flight prompt is evicted.
73 const MAX_ACP_SESSIONS: usize = 64;
74
75 /// A conforming ACP client answers every pending permission request with a
76 /// `cancelled` outcome when the prompt is cancelled. Bound that hand-off so a
77 /// broken client cannot strand the stdio server forever after cancellation.
78 const ACP_PERMISSION_CANCEL_GRACE: Duration = Duration::from_secs(2);
79
80 /// Agent-originated JSON-RPC request ids have their own namespace. Strings
81 /// avoid the client-specific numeric response-id compatibility shim used for
82 /// replies to client-originated requests.
83 static NEXT_ACP_PERMISSION_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
84
85 /// Content is streamed to the model in full (no truncation); this cap only
86 /// bounds how much of a tool's output is echoed into the `tool_call_update`
87 /// notification the editor renders, so a large `File`/`Bash`
88 /// result does not flood the client UI.
89 const TOOL_CALL_CONTENT_PREVIEW_CHARS: usize = 4_000;
90
91 pub async fn run_acp_server(config: Config, model: String, default_cwd: PathBuf) -> Result<()> {
92 let stdin = tokio::io::stdin();
93 let stdout = tokio::io::stdout();
94 let mut reader = BufReader::new(stdin).lines();
95 let mut writer = tokio::io::BufWriter::new(stdout);
96 let mut server = AcpServer::new(config, model, default_cwd);
97
98 while let Some(line) = reader.next_line().await? {
99 if line.trim().is_empty() {
100 continue;
101 }
102
103 let message: Value = match serde_json::from_str(&line) {
104 Ok(value) => value,
105 Err(err) => {
106 write_jsonrpc_error(&mut writer, None, -32700, format!("invalid json: {err}"))
107 .await?;
108 continue;
109 }
110 };
111
112 if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
113 write_jsonrpc_error(
114 &mut writer,
115 message
116 .get("id")
117 .cloned()
118 .map(|id| server.response_id_policy.response_id(id)),
119 -32600,
120 "jsonrpc version must be 2.0",
121 )
122 .await?;
123 continue;
124 }
125
126 let id = message.get("id").cloned();
127 let method = match message.get("method").and_then(Value::as_str) {
128 Some(method) => method,
129 None if is_jsonrpc_response(&message) => {
130 // A late response to an agent-originated request (most notably
131 // permission after cancellation) has no request semantics and
132 // must not be answered with another JSON-RPC error.
133 continue;
134 }
135 None => {
136 write_jsonrpc_error(
137 &mut writer,
138 id.map(|id| server.response_id_policy.response_id(id)),
139 -32600,
140 "missing method",
141 )
142 .await?;
143 continue;
144 }
145 };
146 let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
147
148 // `session/prompt` is driven concurrently with the reader so a
149 // `session/cancel` can interrupt the in-flight provider call or a
150 // running tool. Every other method is request/response and handled
151 // synchronously below.
152 if method == "session/prompt" {
153 match server.begin_prompt(params) {
154 Ok(prepared) => {
155 let PreparedPrompt {
156 session_id,
157 messages,
158 cwd,
159 config,
160 model,
161 } = prepared;
162 let response_id_policy = server.response_id_policy;
163 let Some(tool_registry) = server.session_tool_registry(&session_id) else {
164 let id = id.map(|id| response_id_policy.response_id(id));
165 write_jsonrpc_error(&mut writer, id, -32603, "unknown sessionId").await?;
166 continue;
167 };
168 // Freeze the first round's fully composed system prompt
169 // for this entire `session/prompt`. Tool calls may edit
170 // AGENTS.md, memory, or configured instruction files, but
171 // self-authored content cannot become same-turn system
172 // authority on a later provider round.
173 let frozen_system_prompt =
174 Arc::new(std::sync::Mutex::new(None::<SystemPrompt>));
175 // The stream-opening closure borrows `&server` only
176 // briefly per round; each returned `StreamEventBox` is
177 // `'static`, so it can be raced against the reader
178 // without holding a borrow on the server across an
179 // await, and the main task keeps exclusive ownership of
180 // stdout.
181 let outcome = run_agentic_prompt_turn(
182 AcpTurnContext {
183 config: &config,
184 model: &model,
185 session_id: &session_id,
186 tool_registry: &tool_registry,
187 response_id_policy,
188 },
189 messages,
190 &mut reader,
191 &mut writer,
192 |msgs| {
193 // Rebind to references before the `async move`
194 // block: `async move` moves every path it
195 // touches, and these are already-`Copy`
196 // references, so only `msgs` (the per-round
197 // owned clone) is actually moved in — `server`,
198 // `cwd`, and `tool_registry` stay borrowed from
199 // the enclosing scope across every call this
200 // `FnMut` closure makes.
201 let server = &server;
202 let cwd = &cwd;
203 let config = &config;
204 let model = &model;
205 let tool_registry = &tool_registry;
206 let frozen_system_prompt = Arc::clone(&frozen_system_prompt);
207 async move {
208 server
209 .open_prompt_stream(
210 config,
211 model,
212 &msgs,
213 cwd,
214 tool_registry,
215 &frozen_system_prompt,
216 )
217 .await
218 }
219 },
220 )
221 .await;
222 match outcome {
223 Ok((PromptOutcome::Completed(_text), full_messages)) => {
224 // Chunks were already streamed; record the full
225 // conversation (including any tool rounds) for
226 // the next prompt.
227 server.commit_turn_messages(&session_id, full_messages);
228 if let Some(id) = id {
229 let id = response_id_policy.response_id(id);
230 write_jsonrpc_result(
231 &mut writer,
232 id,
233 json!({ "stopReason": "end_turn" }),
234 )
235 .await?;
236 }
237 }
238 Ok((PromptOutcome::Cancelled, partial_messages)) => {
239 // The turn driver keeps complete receipts for every
240 // proposed tool call, including calls cancelled
241 // before execution, so partial side effects remain
242 // visible and no dangling tool_use block is stored.
243 server.commit_turn_messages(&session_id, partial_messages);
244 if let Some(id) = id {
245 let id = response_id_policy.response_id(id);
246 write_jsonrpc_result(
247 &mut writer,
248 id,
249 json!({ "stopReason": "cancelled" }),
250 )
251 .await?;
252 }
253 }
254 Ok((PromptOutcome::MaxRounds(_text), full_messages)) => {
255 // Max rounds reached — commit what we have
256 // (unlike cancel, this is a normal completion).
257 server.commit_turn_messages(&session_id, full_messages);
258 if let Some(id) = id {
259 let id = response_id_policy.response_id(id);
260 write_jsonrpc_result(
261 &mut writer,
262 id,
263 json!({ "stopReason": "max_turn_requests" }),
264 )
265 .await?;
266 }
267 }
268 Err(err) => {
269 if let Some(partial_messages) = err.partial_messages {
270 // A later provider round failed after one or
271 // more tools completed. Preserve those
272 // side-effect receipts in session history.
273 server.commit_turn_messages(&session_id, partial_messages);
274 } else {
275 // The user message was already pushed into
276 // session history by `begin_prompt`; roll it
277 // back when no tool receipt exists yet.
278 server.rollback_user_message(&session_id);
279 }
280 let id = id.map(|id| response_id_policy.response_id(id));
281 write_jsonrpc_error(&mut writer, id, -32603, err.source.to_string())
282 .await?;
283 }
284 }
285 }
286 Err(err) => {
287 let id = id.map(|id| server.response_id_policy.response_id(id));
288 write_jsonrpc_error(&mut writer, id, err.code, err.message).await?;
289 }
290 }
291 continue;
292 }
293
294 match server.handle_request(method, params).await {
295 Ok(AcpDispatch::Response(result)) => {
296 if let Some(id) = id {
297 let id = server.response_id_policy.response_id(id);
298 write_jsonrpc_result(&mut writer, id, result).await?;
299 }
300 }
301 Ok(AcpDispatch::Shutdown) => {
302 if let Some(id) = id {
303 let id = server.response_id_policy.response_id(id);
304 write_jsonrpc_result(&mut writer, id, json!(null)).await?;
305 }
306 break;
307 }
308 Err(err) => {
309 let id = id.map(|id| server.response_id_policy.response_id(id));
310 write_jsonrpc_error(&mut writer, id, err.code, err.message).await?;
311 }
312 }
313 }
314
315 Ok(())
316 }
317
318 fn is_jsonrpc_response(message: &Value) -> bool {
319 message.get("id").is_some()
320 && (message.get("result").is_some() || message.get("error").is_some())
321 }
322
323 /// Outcome of a `session/prompt` turn driven against the input stream.
324 #[derive(Debug, PartialEq, Eq)]
325 enum PromptOutcome {
326 /// The provider call finished first; carries the assistant text.
327 Completed(String),
328 /// A matching `session/cancel` arrived before the call finished.
329 Cancelled,
330 /// The turn reached the maximum number of tool-call round-trips.
331 /// Carries whatever text the model produced in the final round.
332 MaxRounds(String),
333 }
334
335 #[derive(Debug)]
336 struct AgenticPromptError {
337 source: anyhow::Error,
338 /// Present once at least one complete tool-result batch has been appended.
339 /// Those receipts may describe real side effects and must survive a later
340 /// provider failure.
341 partial_messages: Option<Vec<Message>>,
342 }
343
344 impl std::fmt::Display for AgenticPromptError {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 self.source.fmt(f)
347 }
348 }
349
350 impl AgenticPromptError {
351 fn new(source: anyhow::Error, messages: &[Message], has_tool_receipts: bool) -> Self {
352 Self {
353 source,
354 partial_messages: has_tool_receipts.then(|| messages.to_vec()),
355 }
356 }
357 }
358
359 /// A tool call the model requested, assembled from streamed
360 /// `content_block_start` / `content_block_delta` / `content_block_stop`
361 /// events. `parse_error` is set when the accumulated `input_json_delta`
362 /// bytes did not parse as JSON — the call is still surfaced (rather than
363 /// silently dropped) so the model gets a clear tool-result error instead of
364 /// the turn hanging.
365 #[derive(Debug, Clone)]
366 struct PendingToolCall {
367 id: String,
368 name: String,
369 input: Value,
370 parse_error: Option<String>,
371 }
372
373 /// Accumulates one streamed `tool_use` content block until its
374 /// `content_block_stop`.
375 #[derive(Debug, Default)]
376 struct ToolUseAccumulator {
377 id: String,
378 name: String,
379 initial_input: Value,
380 buffer: String,
381 }
382
383 impl ToolUseAccumulator {
384 fn finalize(self) -> PendingToolCall {
385 if self.buffer.trim().is_empty() {
386 return PendingToolCall {
387 id: self.id,
388 name: self.name,
389 input: self.initial_input,
390 parse_error: None,
391 };
392 }
393 match serde_json::from_str(&self.buffer) {
394 Ok(input) => PendingToolCall {
395 id: self.id,
396 name: self.name,
397 input,
398 parse_error: None,
399 },
400 Err(_) => PendingToolCall {
401 id: self.id,
402 name: self.name,
403 input: json!({}),
404 parse_error: Some(self.buffer),
405 },
406 }
407 }
408 }
409
410 /// The text payload an ACP client should see for a given stream event, if any.
411 /// ACP baseline is text-only, so thinking/tool/control events carry no chunk.
412 fn stream_text_chunk(event: &StreamEvent) -> Option<&str> {
413 match event {
414 StreamEvent::ContentBlockDelta {
415 delta: Delta::TextDelta { text },
416 ..
417 } => Some(text),
418 StreamEvent::ContentBlockStart {
419 content_block: ContentBlockStart::Text { text },
420 ..
421 } => Some(text),
422 _ => None,
423 }
424 }
425
426 /// Consume a provider response `stream`, emitting each text delta as a
427 /// `session/update` chunk, while concurrently watching `reader` for a
428 /// `session/cancel` targeting `session_id`.
429 ///
430 /// This is the streaming + cancellation control point. It is generic over the
431 /// reader/writer and takes the boxed stream, so it is unit-tested with canned
432 /// in-memory streams and readers — no real provider call required. The caller
433 /// keeps the only writer, so streamed chunks and acknowledgements all stay on
434 /// the single protocol-clean stdout stream.
435 ///
436 /// Returns [`PromptOutcome::Completed`] with the full accumulated text once the
437 /// stream ends (or emits `message_stop`), plus any `tool_use` blocks the model
438 /// emitted during the round, so the caller can execute them and continue the
439 /// turn. A matching `session/cancel` (request or notification form) ends it
440 /// early with [`PromptOutcome::Cancelled`] — dropping the stream aborts the
441 /// underlying provider connection. The turn is single-flight: a cancel for a
442 /// different session is acknowledged and ignored; any other concurrent *request*
443 /// is rejected with a clear error so the client is not left waiting;
444 /// notifications without an id are ignored.
445 async fn drive_prompt_stream<R, W>(
446 mut stream: StreamEventBox,
447 session_id: &str,
448 response_id_policy: JsonRpcResponseIdPolicy,
449 reader: &mut Lines<R>,
450 writer: &mut W,
451 ) -> Result<(PromptOutcome, Vec<PendingToolCall>)>
452 where
453 R: AsyncBufRead + Unpin,
454 W: AsyncWrite + Unpin,
455 {
456 let mut accumulated = String::new();
457 let mut tool_calls: Vec<PendingToolCall> = Vec::new();
458 let mut pending_tool_uses: HashMap<u32, ToolUseAccumulator> = HashMap::new();
459 // Once input closes mid-turn we stop selecting on the reader and just drain
460 // the stream to completion, rather than spinning on repeated EOFs.
461 let mut reader_open = true;
462 loop {
463 tokio::select! {
464 event = stream.next() => {
465 match event {
466 // Stream exhausted without an explicit stop: turn is done.
467 None => return Ok((PromptOutcome::Completed(accumulated), tool_calls)),
468 Some(Ok(event)) => {
469 if let Some(text) = stream_text_chunk(&event)
470 && !text.is_empty() {
471 accumulated.push_str(text);
472 write_session_update(writer, session_id, text.to_string()).await?;
473 }
474 match event {
475 StreamEvent::ContentBlockStart {
476 index,
477 content_block: ContentBlockStart::ToolUse { id, name, input, ..},
478 } => {
479 pending_tool_uses.insert(
480 index,
481 ToolUseAccumulator {
482 id,
483 name,
484 initial_input: input,
485 buffer: String::new(),
486 },
487 );
488 }
489 StreamEvent::ContentBlockDelta {
490 index,
491 delta: Delta::InputJsonDelta { partial_json },
492 } => {
493 if let Some(acc) = pending_tool_uses.get_mut(&index) {
494 acc.buffer.push_str(&partial_json);
495 }
496 }
497 StreamEvent::ContentBlockStop { index } => {
498 if let Some(acc) = pending_tool_uses.remove(&index) {
499 tool_calls.push(acc.finalize());
500 }
501 }
502 StreamEvent::MessageStop => {
503 return Ok((PromptOutcome::Completed(accumulated), tool_calls));
504 }
505 StreamEvent::Error { error } => {
506 return Err(anyhow!("provider stream error: {error}"));
507 }
508 _ => {}
509 }
510 }
511 Some(Err(err)) => return Err(err),
512 }
513 }
514 line = reader.next_line(), if reader_open => {
515 let line = match line? {
516 Some(line) => line,
517 // Input closed mid-turn: stop watching it, keep draining.
518 None => {
519 reader_open = false;
520 continue;
521 }
522 };
523 if line.trim().is_empty() {
524 continue;
525 }
526 let message: Value = match serde_json::from_str(&line) {
527 Ok(value) => value,
528 Err(err) => {
529 write_jsonrpc_error(writer, None, -32700, format!("invalid json: {err}"))
530 .await?;
531 continue;
532 }
533 };
534 let id = message.get("id").cloned();
535 match message.get("method").and_then(Value::as_str) {
536 Some("session/cancel") => {
537 let target = message.pointer("/params/sessionId").and_then(Value::as_str);
538 // A cancel with no sessionId is treated as targeting the
539 // single in-flight turn.
540 if target.is_none() || target == Some(session_id) {
541 if let Some(id) = id {
542 let id = response_id_policy.response_id(id);
543 write_jsonrpc_result(writer, id, json!(null)).await?;
544 }
545 // Dropping `stream` on return aborts the provider call.
546 return Ok((PromptOutcome::Cancelled, tool_calls));
547 }
548 // Cancel for some other session: acknowledge, keep going.
549 if let Some(id) = id {
550 let id = response_id_policy.response_id(id);
551 write_jsonrpc_result(writer, id, json!(null)).await?;
552 }
553 }
554 _ => {
555 // The turn is single-flight; do not silently drop a
556 // request the client expects a response to.
557 if let Some(id) = id {
558 let id = response_id_policy.response_id(id);
559 write_jsonrpc_error(
560 writer,
561 Some(id),
562 -32603,
563 "a session/prompt turn is already in progress",
564 )
565 .await?;
566 }
567 }
568 }
569 }
570 }
571 }
572 }
573
574 /// Outcome of executing one batch of tool calls from a single round.
575 enum ToolBatchOutcome {
576 /// Every tool call ran to completion; carries the `tool_result` messages
577 /// to append to the conversation, in call order.
578 Completed(Vec<Message>),
579 /// A matching `session/cancel` arrived while a tool was awaiting approval
580 /// or running. Carries receipts for every proposed call so completed or
581 /// partially completed side effects never disappear from history.
582 Cancelled(Vec<Message>),
583 }
584
585 #[derive(Debug, Clone, PartialEq, Eq)]
586 enum AcpToolAdmission {
587 Auto,
588 RequestPermission(String),
589 Block(String),
590 }
591
592 #[derive(Debug, Clone, PartialEq, Eq)]
593 enum AcpPermissionDecision {
594 Allow,
595 Reject(String),
596 Cancelled,
597 }
598
599 fn acp_shell_command_requests_detach(command: &str) -> bool {
600 let mut single_quoted = false;
601 let mut double_quoted = false;
602 let mut escaped = false;
603 let chars = command.chars().collect::<Vec<_>>();
604 for (index, ch) in chars.iter().copied().enumerate() {
605 if escaped {
606 escaped = false;
607 continue;
608 }
609 if ch == '\\' && !single_quoted {
610 escaped = true;
611 continue;
612 }
613 if ch == '\'' && !double_quoted {
614 single_quoted = !single_quoted;
615 continue;
616 }
617 if ch == '"' && !single_quoted {
618 double_quoted = !double_quoted;
619 continue;
620 }
621 if ch != '&' || single_quoted || double_quoted {
622 continue;
623 }
624 let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
625 let next = chars.get(index + 1).copied();
626 // `&&`, `&>`/`&>>`, and `>&` are chaining/redirection rather than a
627 // detached child. Any other unquoted ampersand is a background
628 // control operator and is unavailable in ACP.
629 if previous != Some('&') && next != Some('&') && next != Some('>') && previous != Some('>')
630 {
631 return true;
632 }
633 }
634
635 shell_words::split(command).is_ok_and(|words| {
636 words.iter().any(|word| {
637 matches!(
638 word.to_ascii_lowercase().as_str(),
639 "nohup" | "disown" | "setsid" | "daemonize"
640 )
641 })
642 })
643 }
644
645 #[derive(Debug)]
646 struct PreparedAcpTool {
647 call: PreparedToolCall,
648 admission: AcpToolAdmission,
649 additional_context: Option<String>,
650 }
651
652 /// Prepare one registered call and fold every policy layer that can tighten
653 /// its admission. ACP deliberately does not use the TUI's workspace-write
654 /// carve-out: a remembered exact allow rule may clear the ordinary tool hold,
655 /// but the built-in safety floor and repository law can always re-add a prompt
656 /// or hard block afterwards.
657 fn prepare_acp_tool_admission(
658 config: &Config,
659 registry: &ToolRegistry,
660 call: &PendingToolCall,
661 ) -> std::result::Result<(PreparedToolCall, AcpToolAdmission), ToolError> {
662 let spec = registry.get(&call.name).ok_or_else(|| {
663 ToolError::not_available(format!("tool '{}' is not registered", call.name))
664 })?;
665 let prepared = spec.prepare(call.input.clone(), registry.context())?;
666 let canonical_name =
667 crate::tools::canonical_action::canonical_action_alias(&call.name, &prepared.input);
668 if matches!(call.name.as_str(), "bash" | "Bash" | "exec_shell")
669 || canonical_name.starts_with("exec_shell")
670 {
671 let action = prepared
672 .input
673 .get("action")
674 .and_then(Value::as_str)
675 .unwrap_or("run");
676 let requests_stateful_shell = action != "run"
677 || prepared.starts_detached
678 || prepared.input.get("interactive").and_then(Value::as_bool) == Some(true)
679 || prepared.input.get("persist").and_then(Value::as_bool) == Some(true)
680 || prepared
681 .input
682 .get("command")
683 .and_then(Value::as_str)
684 .is_some_and(acp_shell_command_requests_detach);
685 if requests_stateful_shell {
686 return Ok((
687 prepared,
688 AcpToolAdmission::Block(
689 "ACP v0.9.6 exposes foreground Bash runs only; background, TTY, interactive, persistent, and background-task control actions are unavailable."
690 .to_string(),
691 ),
692 ));
693 }
694 }
695 let mut permission_reason =
696 (prepared.approval != ApprovalRequirement::Auto).then(|| prepared.description.clone());
697 let approval_mode = acp_approval_mode(config);
698 let workspace = registry.context().workspace.as_path();
699
700 let typed_rule = exec_shell_ask_rule_decision_for_policy(
701 &config.exec_policy_engine,
702 &call.name,
703 &prepared.input,
704 workspace,
705 approval_mode,
706 )
707 .or_else(|| {
708 file_tool_ask_rule_decision_for_policy(
709 &config.exec_policy_engine,
710 &call.name,
711 &prepared.input,
712 workspace,
713 approval_mode,
714 )
715 });
716 match typed_rule {
717 Some(ToolAskRuleDecision::Allow) => permission_reason = None,
718 Some(ToolAskRuleDecision::Prompt(reason)) => permission_reason = Some(reason),
719 Some(ToolAskRuleDecision::Block(reason)) => {
720 return Ok((prepared, AcpToolAdmission::Block(reason)));
721 }
722 None => {}
723 }
724
725 let run_origin = if prepared.starts_detached {
726 crate::tui::auto_review::RunOrigin::Background
727 } else {
728 crate::tui::auto_review::RunOrigin::Headless
729 };
730 let review_context = crate::tui::auto_review::AutoReviewContext::from_tool_call(
731 &call.name,
732 &prepared.input,
733 run_origin,
734 approval_mode,
735 crate::config::is_workspace_trusted(workspace),
736 Some(workspace),
737 );
738 let (auto_review, _audit) =
739 auto_review_plan_decision_for_context(&config.auto_review_policy(), &review_context);
740 match auto_review {
741 AutoReviewPlanDecision::NoChange | AutoReviewPlanDecision::Allow => {}
742 AutoReviewPlanDecision::ForcePrompt(reason) => permission_reason = Some(reason),
743 // Headless adapters keep the deterministic-only tier: a fallback hold
744 // that interactive Auto posture would send to the model guardian is
745 // a hard block here (the reviewer is an interactive-session feature).
746 AutoReviewPlanDecision::ConsultReviewer(reason) | AutoReviewPlanDecision::Block(reason) => {
747 return Ok((prepared, AcpToolAdmission::Block(reason)));
748 }
749 }
750
751 if let Some(repo_law) =
752 crate::repo_law::repo_law_plan_decision(workspace, &call.name, &prepared.input)
753 {
754 match repo_law {
755 crate::repo_law::RepoLawPlanDecision::ForcePrompt(reason) => {
756 permission_reason = Some(reason);
757 }
758 crate::repo_law::RepoLawPlanDecision::Block(reason) => {
759 return Ok((prepared, AcpToolAdmission::Block(reason)));
760 }
761 }
762 }
763
764 let admission = permission_reason
765 .map(AcpToolAdmission::RequestPermission)
766 .unwrap_or(AcpToolAdmission::Auto);
767 // #6337: Bypass pre-approves prompts so an unattended `--yolo` session
768 // executes instead of stalling on permission requests no client answers.
769 // Hard blocks above (safety floor, repo law, reviewer consult) return
770 // early and are never downgraded.
771 let admission = if approval_mode == ApprovalMode::Bypass
772 && matches!(admission, AcpToolAdmission::RequestPermission(_))
773 {
774 AcpToolAdmission::Auto
775 } else {
776 admission
777 };
778 Ok((prepared, admission))
779 }
780
781 /// Run the same strict pre-tool hook gate as the native turn loop, then
782 /// prepare and evaluate policy from the hook's final input. The initial
783 /// preparation is deliberately side-effect free and catches malformed input
784 /// before an operator hook is asked to reason about it; any rewrite is fully
785 /// re-prepared and all policy layers run again from that rewritten value.
786 async fn prepare_acp_tool_with_hooks(
787 config: &Config,
788 model: &str,
789 registry: &ToolRegistry,
790 call: &PendingToolCall,
791 ) -> std::result::Result<PreparedAcpTool, ToolError> {
792 let spec = registry.get(&call.name).ok_or_else(|| {
793 ToolError::not_available(format!("tool '{}' is not registered", call.name))
794 })?;
795 // Initial validation mirrors the native prepare-before-hooks contract.
796 spec.prepare(call.input.clone(), registry.context())?;
797
798 let hook_outcome = run_tool_call_before_hooks(
799 registry.context().runtime.hook_executor.as_ref(),
800 &call.name,
801 &call.id,
802 &call.input,
803 acp_mode(config),
804 registry.context().workspace.as_path(),
805 model,
806 )
807 .await?;
808
809 let mut final_call = call.clone();
810 if let Some(updated_input) = hook_outcome.updated_input {
811 final_call.input = updated_input;
812 }
813 let (prepared, mut admission) = prepare_acp_tool_admission(config, registry, &final_call)?;
814 if hook_outcome.requires_approval && matches!(admission, AcpToolAdmission::Auto) {
815 admission = AcpToolAdmission::RequestPermission(
816 "A ToolCallBefore hook requires explicit approval for this call.".to_string(),
817 );
818 }
819
820 Ok(PreparedAcpTool {
821 call: prepared,
822 admission,
823 additional_context: hook_outcome.additional_context,
824 })
825 }
826
827 fn next_acp_permission_request_id() -> Value {
828 Value::String(format!(
829 "codewhale-permission-{}",
830 NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
831 ))
832 }
833
834 async fn write_tool_permission_request<W>(
835 writer: &mut W,
836 request_id: &Value,
837 session_id: &str,
838 call: &PendingToolCall,
839 reason: &str,
840 ) -> Result<()>
841 where
842 W: AsyncWrite + Unpin,
843 {
844 write_json_line(
845 writer,
846 json!({
847 "jsonrpc": "2.0",
848 "id": request_id,
849 "method": "session/request_permission",
850 "params": {
851 "sessionId": session_id,
852 "toolCall": {
853 "toolCallId": call.id,
854 "title": tool_call_title(call),
855 "kind": tool_call_kind(call),
856 "status": "pending",
857 "rawInput": call.input,
858 "content": [{
859 "type": "content",
860 "content": { "type": "text", "text": reason }
861 }]
862 },
863 "options": [
864 {
865 "optionId": "allow-once",
866 "name": "Allow once",
867 "kind": "allow_once"
868 },
869 {
870 "optionId": "reject-once",
871 "name": "Reject",
872 "kind": "reject_once"
873 }
874 ]
875 }
876 }),
877 )
878 .await
879 }
880
881 /// Ask the ACP client to approve one sensitive call. The client owns the UI
882 /// and ACP v1 requires it to answer a pending request with `cancelled` when the
883 /// prompt turn is cancelled. Unknown, malformed, or errored responses all fail
884 /// closed as rejection; only the exact offered `allow-once` id authorizes work.
885 async fn request_tool_permission<R, W>(
886 reader: &mut Lines<R>,
887 writer: &mut W,
888 response_id_policy: JsonRpcResponseIdPolicy,
889 session_id: &str,
890 call: &PendingToolCall,
891 reason: &str,
892 ) -> Result<AcpPermissionDecision>
893 where
894 R: AsyncBufRead + Unpin,
895 W: AsyncWrite + Unpin,
896 {
897 let request_id = next_acp_permission_request_id();
898 write_tool_permission_request(writer, &request_id, session_id, call, reason).await?;
899 let mut cancel_deadline = None;
900
901 loop {
902 let line = if let Some(deadline) = cancel_deadline {
903 match tokio::time::timeout_at(deadline, reader.next_line()).await {
904 Ok(line) => line?,
905 Err(_) => return Ok(AcpPermissionDecision::Cancelled),
906 }
907 } else {
908 reader.next_line().await?
909 };
910 let Some(line) = line else {
911 return Ok(AcpPermissionDecision::Reject(
912 "Permission denied: ACP client disconnected before answering.".to_string(),
913 ));
914 };
915 if line.trim().is_empty() {
916 continue;
917 }
918 let message: Value = match serde_json::from_str(&line) {
919 Ok(value) => value,
920 Err(err) => {
921 write_jsonrpc_error(writer, None, -32700, format!("invalid json: {err}")).await?;
922 continue;
923 }
924 };
925
926 if let Some(method) = message.get("method").and_then(Value::as_str) {
927 let message_id = message.get("id").cloned();
928 if method == "session/cancel" {
929 let target = message.pointer("/params/sessionId").and_then(Value::as_str);
930 if target.is_none() || target == Some(session_id) {
931 if let Some(message_id) = message_id {
932 let message_id = response_id_policy.response_id(message_id);
933 write_jsonrpc_result(writer, message_id, json!(null)).await?;
934 }
935 cancel_deadline.get_or_insert_with(|| {
936 tokio::time::Instant::now() + ACP_PERMISSION_CANCEL_GRACE
937 });
938 continue;
939 }
940 if let Some(message_id) = message_id {
941 let message_id = response_id_policy.response_id(message_id);
942 write_jsonrpc_result(writer, message_id, json!(null)).await?;
943 }
944 continue;
945 }
946
947 if let Some(message_id) = message_id {
948 let message_id = response_id_policy.response_id(message_id);
949 write_jsonrpc_error(
950 writer,
951 Some(message_id),
952 -32603,
953 "a session/prompt turn is already in progress",
954 )
955 .await?;
956 }
957 continue;
958 }
959
960 if message.get("id") != Some(&request_id) {
961 // This is a response, not a request; there is nothing valid to send
962 // back. Ignore stale/unrelated agent-response traffic without
963 // allowing it to satisfy the permission gate.
964 continue;
965 }
966 if cancel_deadline.is_some() {
967 return Ok(AcpPermissionDecision::Cancelled);
968 }
969 if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
970 return Ok(AcpPermissionDecision::Reject(
971 "Permission denied: malformed ACP response.".to_string(),
972 ));
973 }
974 if message.get("error").is_some() {
975 return Ok(AcpPermissionDecision::Reject(
976 "Permission denied: ACP client returned an error.".to_string(),
977 ));
978 }
979 match message
980 .pointer("/result/outcome/outcome")
981 .and_then(Value::as_str)
982 {
983 Some("cancelled") => return Ok(AcpPermissionDecision::Cancelled),
984 Some("selected") => {
985 let option_id = message
986 .pointer("/result/outcome/optionId")
987 .and_then(Value::as_str);
988 return Ok(match option_id {
989 Some("allow-once") => AcpPermissionDecision::Allow,
990 Some("reject-once") => AcpPermissionDecision::Reject(
991 "Permission denied by the user; the tool was not executed.".to_string(),
992 ),
993 _ => AcpPermissionDecision::Reject(
994 "Permission denied: ACP client selected an unknown option.".to_string(),
995 ),
996 });
997 }
998 _ => {
999 return Ok(AcpPermissionDecision::Reject(
1000 "Permission denied: malformed ACP response.".to_string(),
1001 ));
1002 }
1003 }
1004 }
1005 }
1006
1007 async fn record_tool_execution_result<W>(
1008 writer: &mut W,
1009 session_id: &str,
1010 call: &PendingToolCall,
1011 result: std::result::Result<RichToolResult, ToolError>,
1012 ) -> Result<Message>
1013 where
1014 W: AsyncWrite + Unpin,
1015 {
1016 let (content, is_error, rich_blocks) = match result {
1017 Ok(tool_result) => (
1018 tool_result.result.content,
1019 !tool_result.result.success,
1020 tool_result.content_blocks,
1021 ),
1022 Err(err) => (format!("Error: {err}"), true, Vec::new()),
1023 };
1024 let status = if is_error { "failed" } else { "completed" };
1025 write_tool_call_update_with_blocks(
1026 writer,
1027 session_id,
1028 call,
1029 status,
1030 Some(&content),
1031 &rich_blocks,
1032 )
1033 .await?;
1034 Ok(tool_result_message_with_blocks(
1035 &call.id,
1036 content,
1037 is_error,
1038 rich_blocks
1039 .iter()
1040 .filter_map(|block| serde_json::to_value(block).ok())
1041 .collect(),
1042 ))
1043 }
1044
1045 async fn record_unstarted_cancelled_calls<W, I>(
1046 writer: &mut W,
1047 session_id: &str,
1048 calls: I,
1049 ) -> Result<Vec<Message>>
1050 where
1051 W: AsyncWrite + Unpin,
1052 I: IntoIterator<Item = PendingToolCall>,
1053 {
1054 let mut messages = Vec::new();
1055 for call in calls {
1056 write_tool_call_start(writer, session_id, &call).await?;
1057 let content = "Cancelled before execution; the tool was not run.";
1058 write_tool_call_update(writer, session_id, &call, "failed", Some(content)).await?;
1059 messages.push(tool_result_message(&call.id, content.to_string(), true));
1060 }
1061 Ok(messages)
1062 }
1063
1064 #[derive(Clone, Copy)]
1065 struct AcpTurnContext<'a> {
1066 config: &'a Config,
1067 model: &'a str,
1068 session_id: &'a str,
1069 tool_registry: &'a ToolRegistry,
1070 response_id_policy: JsonRpcResponseIdPolicy,
1071 }
1072
1073 /// Execute `tool_calls` in order against `registry`, reporting each one to
1074 /// the client as `tool_call` / `tool_call_update` session updates, while
1075 /// racing every execution against the reader for a `session/cancel`
1076 /// targeting `session_id`. On cancel, the tool's [`CancellationToken`] is
1077 /// signalled and the in-flight call is awaited to completion (so a
1078 /// cancel-aware tool like `Bash` gets a chance to kill its child
1079 /// process) before returning [`ToolBatchOutcome::Cancelled`].
1080 async fn execute_tool_calls_with_cancellation<R, W>(
1081 context: AcpTurnContext<'_>,
1082 tool_calls: Vec<PendingToolCall>,
1083 reader: &mut Lines<R>,
1084 writer: &mut W,
1085 ) -> Result<ToolBatchOutcome>
1086 where
1087 R: AsyncBufRead + Unpin,
1088 W: AsyncWrite + Unpin,
1089 {
1090 let AcpTurnContext {
1091 config,
1092 model,
1093 session_id,
1094 tool_registry: registry,
1095 response_id_policy,
1096 } = context;
1097 let mut result_messages = Vec::with_capacity(tool_calls.len());
1098 let mut reader_open = true;
1099 let mut calls = tool_calls.into_iter();
1100
1101 while let Some(mut call) = calls.next() {
1102 write_tool_call_start(writer, session_id, &call).await?;
1103
1104 if let Some(parse_error) = call.parse_error.clone() {
1105 let content = format!("Error: tool arguments were not valid JSON: {parse_error}");
1106 write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
1107 result_messages.push(tool_result_message(&call.id, content, true));
1108 continue;
1109 }
1110
1111 let prepared = match prepare_acp_tool_with_hooks(config, model, registry, &call).await {
1112 Ok(prepared) => prepared,
1113 Err(err) => {
1114 let content = format!("Error: {err}");
1115 write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
1116 result_messages.push(tool_result_message(&call.id, content, true));
1117 continue;
1118 }
1119 };
1120 call.input = prepared.call.input;
1121
1122 match prepared.admission {
1123 AcpToolAdmission::Auto => {}
1124 AcpToolAdmission::Block(reason) => {
1125 let content = format!("Blocked by Codewhale policy: {reason}");
1126 write_tool_call_update(writer, session_id, &call, "failed", Some(&content)).await?;
1127 result_messages.push(tool_result_message(&call.id, content, true));
1128 continue;
1129 }
1130 AcpToolAdmission::RequestPermission(reason) => {
1131 match request_tool_permission(
1132 reader,
1133 writer,
1134 response_id_policy,
1135 session_id,
1136 &call,
1137 &reason,
1138 )
1139 .await?
1140 {
1141 AcpPermissionDecision::Allow => {}
1142 AcpPermissionDecision::Reject(content) => {
1143 write_tool_call_update(writer, session_id, &call, "failed", Some(&content))
1144 .await?;
1145 result_messages.push(tool_result_message(&call.id, content, true));
1146 continue;
1147 }
1148 AcpPermissionDecision::Cancelled => {
1149 let content = "Cancelled while awaiting permission; the tool was not run.";
1150 write_tool_call_update(writer, session_id, &call, "failed", Some(content))
1151 .await?;
1152 result_messages.push(tool_result_message(
1153 &call.id,
1154 content.to_string(),
1155 true,
1156 ));
1157 result_messages.extend(
1158 record_unstarted_cancelled_calls(writer, session_id, calls).await?,
1159 );
1160 return Ok(ToolBatchOutcome::Cancelled(result_messages));
1161 }
1162 }
1163 }
1164 }
1165
1166 write_tool_call_update(writer, session_id, &call, "in_progress", None).await?;
1167
1168 let cancel_token = CancellationToken::new();
1169 let mut turn_context = registry.context().clone();
1170 turn_context.cancel_token = Some(cancel_token.clone());
1171 let exec_fut = registry.execute_rich_full_with_context(
1172 &call.name,
1173 call.input.clone(),
1174 Some(&turn_context),
1175 );
1176 tokio::pin!(exec_fut);
1177
1178 let mut cancelled = false;
1179 let exec_result = loop {
1180 tokio::select! {
1181 result = &mut exec_fut => break result,
1182 line = reader.next_line(), if reader_open => {
1183 let line = match line? {
1184 Some(line) => line,
1185 None => {
1186 reader_open = false;
1187 continue;
1188 }
1189 };
1190 if line.trim().is_empty() {
1191 continue;
1192 }
1193 let message: Value = match serde_json::from_str(&line) {
1194 Ok(value) => value,
1195 Err(err) => {
1196 write_jsonrpc_error(writer, None, -32700, format!("invalid json: {err}"))
1197 .await?;
1198 continue;
1199 }
1200 };
1201 let msg_id = message.get("id").cloned();
1202 match message.get("method").and_then(Value::as_str) {
1203 Some("session/cancel") => {
1204 let target = message.pointer("/params/sessionId").and_then(Value::as_str);
1205 if target.is_none() || target == Some(session_id) {
1206 if let Some(msg_id) = msg_id {
1207 let msg_id = response_id_policy.response_id(msg_id);
1208 write_jsonrpc_result(writer, msg_id, json!(null)).await?;
1209 }
1210 cancel_token.cancel();
1211 // Give the tool a chance to observe the token and
1212 // wind down (e.g. kill a running child process)
1213 // before we drop it.
1214 cancelled = true;
1215 break (&mut exec_fut).await;
1216 }
1217 if let Some(msg_id) = msg_id {
1218 let msg_id = response_id_policy.response_id(msg_id);
1219 write_jsonrpc_result(writer, msg_id, json!(null)).await?;
1220 }
1221 }
1222 _ => {
1223 if let Some(msg_id) = msg_id {
1224 let msg_id = response_id_policy.response_id(msg_id);
1225 write_jsonrpc_error(
1226 writer,
1227 Some(msg_id),
1228 -32603,
1229 "a session/prompt turn is already in progress",
1230 )
1231 .await?;
1232 }
1233 }
1234 }
1235 }
1236 }
1237 };
1238
1239 let exec_result = exec_result.map(|mut result| {
1240 if let Some(context) = prepared.additional_context.as_deref() {
1241 result.result.content =
1242 format!("{}\n\n[hook context] {context}", result.result.content);
1243 }
1244 result
1245 });
1246 result_messages
1247 .push(record_tool_execution_result(writer, session_id, &call, exec_result).await?);
1248 if cancelled {
1249 result_messages
1250 .extend(record_unstarted_cancelled_calls(writer, session_id, calls).await?);
1251 return Ok(ToolBatchOutcome::Cancelled(result_messages));
1252 }
1253 }
1254
1255 Ok(ToolBatchOutcome::Completed(result_messages))
1256 }
1257
1258 fn tool_result_message(tool_use_id: &str, content: String, is_error: bool) -> Message {
1259 tool_result_message_with_blocks(tool_use_id, content, is_error, Vec::new())
1260 }
1261
1262 fn tool_result_message_with_blocks(
1263 tool_use_id: &str,
1264 content: String,
1265 is_error: bool,
1266 content_blocks: Vec<Value>,
1267 ) -> Message {
1268 Message {
1269 role: Role::User,
1270 content: vec![ContentBlock::ToolResult {
1271 tool_use_id: tool_use_id.to_string(),
1272 content,
1273 is_error: Some(is_error),
1274 content_blocks: (!content_blocks.is_empty()).then_some(content_blocks),
1275 }],
1276 }
1277 }
1278
1279 /// Drive one `session/prompt` turn to completion, looping through as many
1280 /// LLM <-> tool round-trips as the model requests (bounded by
1281 /// [`MAX_ACP_TOOL_ROUNDS`]).
1282 ///
1283 /// Recorded interim exception to the one-turn-loop rule (#6088, named in
1284 /// `crates/core/tests/single_turn_loop.rs`): ACP IDE sessions do not run on
1285 /// the full thread/turn runtime yet. #5835 converges them onto
1286 /// `Engine::run_turn` and deletes this loop along with the exception.
1287 ///
1288 /// `open_stream` opens a fresh provider stream for the given message
1289 /// history; production callers wire it to [`AcpServer::open_prompt_stream`],
1290 /// while tests supply canned per-round streams so the loop can be exercised
1291 /// without a real provider. Returns the outcome of the final round plus the
1292 /// full message history (including any tool-call/tool-result rounds), which
1293 /// the caller commits to session history only when the turn completed
1294 /// normally.
1295 ///
1296 /// `open_stream` takes the message history *by value* (a clone per round)
1297 /// rather than `&[Message]`: an `async fn`'s returned future captures the
1298 /// lifetime of every reference parameter, so a borrowed slice here would
1299 /// force `Fut` to depend on each call's borrow lifetime — which `FnMut`'s
1300 /// single associated `Fut` type cannot express. Taking ownership sidesteps
1301 /// that; production callers move the clone into an `async move` block.
1302 async fn run_agentic_prompt_turn<R, W, F, Fut>(
1303 context: AcpTurnContext<'_>,
1304 mut messages: Vec<Message>,
1305 reader: &mut Lines<R>,
1306 writer: &mut W,
1307 mut open_stream: F,
1308 ) -> std::result::Result<(PromptOutcome, Vec<Message>), AgenticPromptError>
1309 where
1310 R: AsyncBufRead + Unpin,
1311 W: AsyncWrite + Unpin,
1312 F: FnMut(Vec<Message>) -> Fut,
1313 Fut: Future<Output = Result<StreamEventBox>>,
1314 {
1315 let AcpTurnContext {
1316 session_id,
1317 response_id_policy,
1318 ..
1319 } = context;
1320 let mut has_tool_receipts = false;
1321 for _round in 0..MAX_ACP_TOOL_ROUNDS {
1322 let stream = open_stream(messages.clone())
1323 .await
1324 .map_err(|error| AgenticPromptError::new(error, &messages, has_tool_receipts))?;
1325 let (outcome, tool_calls) =
1326 drive_prompt_stream(stream, session_id, response_id_policy, reader, writer)
1327 .await
1328 .map_err(|error| AgenticPromptError::new(error, &messages, has_tool_receipts))?;
1329
1330 let text = match outcome {
1331 PromptOutcome::Cancelled => return Ok((PromptOutcome::Cancelled, messages)),
1332 PromptOutcome::Completed(text) => text,
1333 PromptOutcome::MaxRounds(text) => text,
1334 };
1335
1336 let mut assistant_content = Vec::new();
1337 if !text.is_empty() {
1338 assistant_content.push(ContentBlock::Text {
1339 text: text.clone(),
1340 cache_control: None,
1341 });
1342 }
1343 for call in &tool_calls {
1344 assistant_content.push(ContentBlock::ToolUse {
1345 id: call.id.clone(),
1346 name: call.name.clone(),
1347 input: call.input.clone(),
1348 caller: None,
1349 thought_signature: None,
1350 });
1351 }
1352 if !assistant_content.is_empty() {
1353 messages.push(Message {
1354 role: Role::Assistant,
1355 content: assistant_content,
1356 });
1357 }
1358
1359 if tool_calls.is_empty() {
1360 return Ok((PromptOutcome::Completed(text), messages));
1361 }
1362
1363 let batch = execute_tool_calls_with_cancellation(context, tool_calls, reader, writer)
1364 .await
1365 .map_err(|error| AgenticPromptError::new(error, &messages, has_tool_receipts))?;
1366 match batch {
1367 ToolBatchOutcome::Cancelled(tool_result_messages) => {
1368 messages.extend(tool_result_messages);
1369 return Ok((PromptOutcome::Cancelled, messages));
1370 }
1371 ToolBatchOutcome::Completed(tool_result_messages) => {
1372 messages.extend(tool_result_messages);
1373 has_tool_receipts = true;
1374 }
1375 }
1376 }
1377
1378 // Max rounds reached: return the text accumulated in the final round
1379 // rather than an error, so the client gets a structured completion
1380 // with a clear stop reason.
1381 let final_text = messages
1382 .iter()
1383 .rev()
1384 .find(|m| m.role == "assistant")
1385 .and_then(|m| {
1386 m.content.iter().find_map(|b| match b {
1387 ContentBlock::Text { text, .. } => Some(text.clone()),
1388 _ => None,
1389 })
1390 })
1391 .unwrap_or_default();
1392 Ok((PromptOutcome::MaxRounds(final_text), messages))
1393 }
1394
1395 struct AcpServer {
1396 config: Config,
1397 model: String,
1398 default_cwd: PathBuf,
1399 sessions: HashMap<String, AcpSession>,
1400 /// Insertion-order tracking of session ids. Used to evict the *oldest*
1401 /// session (by insertion order, not arbitrary HashMap iteration) when
1402 /// the session cap is reached.
1403 insertion_order: VecDeque<String>,
1404 /// Whether the connected client accepts `terminal` tool calls, from
1405 /// `initialize` params `clientCapabilities.terminal`. Defaults to `false`
1406 /// (restrictive): clients that omit the field get no shell access. Older
1407 /// ACP clients predating the `terminal` capability get a working agent
1408 /// without shell, which is safe; the client can re-declare support when it
1409 /// reconnects.
1410 client_supports_terminal: bool,
1411 response_id_policy: JsonRpcResponseIdPolicy,
1412 }
1413
1414 struct AcpSession {
1415 cwd: PathBuf,
1416 messages: Vec<Message>,
1417 config: Config,
1418 model: String,
1419 /// Built once per session over the session `cwd`, then reused for every
1420 /// prompt turn: `to_api_tools()` memoises the serialised catalog, and
1421 /// `file_read_tracker` / the shell manager need to persist across turns.
1422 tool_registry: Arc<ToolRegistry>,
1423 }
1424
1425 /// The `&mut self` result of validating a `session/prompt`: the user turn is
1426 /// already recorded, and the cloned conversation + cwd are ready for the
1427 /// borrow-free provider call that the prompt driver races against cancellation.
1428 struct PreparedPrompt {
1429 session_id: String,
1430 messages: Vec<Message>,
1431 cwd: PathBuf,
1432 config: Config,
1433 model: String,
1434 }
1435
1436 enum AcpDispatch {
1437 Response(Value),
1438 Shutdown,
1439 }
1440
1441 #[derive(Debug)]
1442 struct AcpError {
1443 code: i32,
1444 message: String,
1445 }
1446
1447 impl AcpServer {
1448 fn new(config: Config, model: String, default_cwd: PathBuf) -> Self {
1449 Self {
1450 config,
1451 model,
1452 default_cwd,
1453 sessions: HashMap::new(),
1454 insertion_order: VecDeque::new(),
1455 client_supports_terminal: false,
1456 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
1457 }
1458 }
1459
1460 // `session/prompt` is handled in the main loop (it needs to run concurrently
1461 // with the reader for cancellation); every other method is request/response.
1462 async fn handle_request(
1463 &mut self,
1464 method: &str,
1465 params: Value,
1466 ) -> std::result::Result<AcpDispatch, AcpError> {
1467 match method {
1468 "initialize" => {
1469 if let Some(terminal) = params
1470 .pointer("/clientCapabilities/terminal")
1471 .and_then(Value::as_bool)
1472 {
1473 self.client_supports_terminal = terminal;
1474 }
1475 self.response_id_policy = JsonRpcResponseIdPolicy::from_initialize_params(&params);
1476 Ok(AcpDispatch::Response(initialize_result(
1477 params.get("protocolVersion").and_then(Value::as_u64),
1478 &self.config,
1479 )))
1480 }
1481 "session/new" => Ok(AcpDispatch::Response(self.new_session(params)?)),
1482 "session/list" => Ok(AcpDispatch::Response(self.list_sessions(params)?)),
1483 "session/load" => Ok(AcpDispatch::Response(self.load_session(params)?)),
1484 "session/listProviders" => Ok(AcpDispatch::Response(self.list_providers())),
1485 "session/currentModel" => Ok(AcpDispatch::Response(self.current_model())),
1486 "session/selectModel" => Ok(AcpDispatch::Response(self.select_model(params)?)),
1487 "session/set_config_option" => {
1488 Ok(AcpDispatch::Response(self.set_session_config(params)?))
1489 }
1490 "session/set_mode" | "session/set_model" => {
1491 let (config_id, field) = if method == "session/set_mode" {
1492 ("mode", "modeId")
1493 } else {
1494 ("model", "modelId")
1495 };
1496 self.set_session_config(json!({
1497 "sessionId": params.get("sessionId"),
1498 "configId": config_id,
1499 "value": params.get(field),
1500 }))?;
1501 Ok(AcpDispatch::Response(json!({})))
1502 }
1503 // A cancel that arrives with no prompt in flight is an idempotent
1504 // no-op (the in-flight case is handled by the prompt driver).
1505 "session/cancel" => Ok(AcpDispatch::Response(json!(null))),
1506 "shutdown" => Ok(AcpDispatch::Shutdown),
1507 _ => Err(AcpError::method_not_found(method)),
1508 }
1509 }
1510
1511 fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
1512 let cwd = params
1513 .get("cwd")
1514 .and_then(Value::as_str)
1515 .map(PathBuf::from)
1516 .unwrap_or_else(|| self.default_cwd.clone());
1517 // A bare uuid, the same shape `create_saved_session` produces and the
1518 // same shape `session/list` advertises. The old `codewhale-` prefix put
1519 // this id in a namespace no other method understood, so a client that
1520 // replayed it into `session/load` — the normal thing to do — got
1521 // `-32602` for an id we had just handed it (#6174).
1522 let session_id = uuid::Uuid::new_v4().to_string();
1523 let tool_registry = Arc::new(build_acp_tool_registry(
1524 &self.config,
1525 &cwd,
1526 self.client_supports_terminal,
1527 ));
1528
1529 // Evict oldest session when at capacity.
1530 if self.sessions.len() >= MAX_ACP_SESSIONS {
1531 // `VecDeque` preserves true insertion order; HashMap iteration
1532 // does not. Pop from the front to evict the session created
1533 // earliest.
1534 if let Some(oldest) = self.insertion_order.pop_front() {
1535 self.sessions.remove(&oldest);
1536 }
1537 }
1538
1539 self.insertion_order.push_back(session_id.clone());
1540 self.sessions.insert(
1541 session_id.clone(),
1542 AcpSession {
1543 cwd,
1544 messages: Vec::new(),
1545 config: self.config.clone(),
1546 model: self.model.clone(),
1547 tool_registry,
1548 },
1549 );
1550 Ok(self.session_configuration(&session_id))
1551 }
1552
1553 /// Durable Codewhale sessions an ACP client can resume (#5864).
1554 ///
1555 /// ACP sessions are in-memory and capped; Codewhale's own sessions are the
1556 /// durable record, and an IDE that offers "resume" means those. A store
1557 /// that cannot be read is an empty list, not a failed request: enumeration
1558 /// is discovery, and a client asking what exists should not be broken by a
1559 /// missing sessions directory.
1560 fn list_sessions(&self, params: Value) -> std::result::Result<Value, AcpError> {
1561 let cwd = match params.get("cwd") {
1562 None | Some(Value::Null) => None,
1563 Some(Value::String(path)) if std::path::Path::new(path).is_absolute() => {
1564 Some(PathBuf::from(path))
1565 }
1566 Some(_) => {
1567 return Err(AcpError::invalid_params(
1568 "session/list cwd must be an absolute path",
1569 ));
1570 }
1571 };
1572 let sessions = Self::session_manager()
1573 .and_then(|manager| manager.list_sessions().ok())
1574 .unwrap_or_default();
1575 let sessions: Vec<Value> = sessions
1576 .into_iter()
1577 .filter(|meta| {
1578 cwd.as_ref().is_none_or(|cwd| {
1579 crate::session_manager::paths_equivalent(&meta.workspace, cwd)
1580 })
1581 })
1582 .map(|meta| {
1583 json!({
1584 "sessionId": meta.id,
1585 "title": meta.title,
1586 "cwd": meta.workspace.to_string_lossy(),
1587 "createdAt": meta.created_at.to_rfc3339(),
1588 "updatedAt": meta.updated_at.to_rfc3339(),
1589 "messageCount": meta.message_count,
1590 })
1591 })
1592 .collect();
1593 Ok(json!({ "sessions": sessions }))
1594 }
1595
1596 /// Rehydrate a durable Codewhale session as this connection's ACP session.
1597 ///
1598 /// The loaded session keeps its own id so a client can list, load, and
1599 /// prompt against one identity. Its `cwd` comes from the saved workspace,
1600 /// not the server default, because the tool registry is built over it.
1601 fn load_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
1602 let session_id = params
1603 .get("sessionId")
1604 .and_then(Value::as_str)
1605 .ok_or_else(|| AcpError::invalid_params("session/load requires sessionId"))?
1606 .to_string();
1607 // Sessions this connection already holds resolve from memory. `session/new`
1608 // sessions live only here — nothing on the ACP path writes them to the
1609 // durable store — so consulting the store first would fail every id we
1610 // minted ourselves. This also makes reloading an already-loaded durable
1611 // session cheap and free of store side effects.
1612 if self.sessions.contains_key(&session_id) {
1613 return Ok(self.session_configuration(&session_id));
1614 }
1615 let manager = Self::session_manager()
1616 .ok_or_else(|| AcpError::internal("no Codewhale session store is available"))?;
1617 let saved = manager
1618 .resume_session_by_prefix(&session_id)
1619 .map_err(|error| {
1620 AcpError::invalid_params(format!("could not load session {session_id}: {error}"))
1621 })?
1622 .session;
1623
1624 let cwd = saved.metadata.workspace.clone();
1625 let tool_registry = Arc::new(build_acp_tool_registry(
1626 &self.config,
1627 &cwd,
1628 self.client_supports_terminal,
1629 ));
1630 let resolved_id = saved.metadata.id.clone();
1631 // A short prefix can resolve to an id this connection already
1632 // tracks: the in-memory fast path above checked the prefix, not the
1633 // resolved id. Pushing again would duplicate the id in
1634 // `insertion_order` while `sessions.insert` merely overwrites, and a
1635 // later capacity eviction would then pop the stale front copy and
1636 // remove a live, recently reloaded session (#6245).
1637 if self.sessions.contains_key(&resolved_id) {
1638 return Ok(self.session_configuration(&resolved_id));
1639 }
1640 if self.sessions.len() >= MAX_ACP_SESSIONS
1641 && let Some(oldest) = self.insertion_order.pop_front()
1642 {
1643 self.sessions.remove(&oldest);
1644 }
1645 self.insertion_order.push_back(resolved_id.clone());
1646 self.sessions.insert(
1647 resolved_id.clone(),
1648 AcpSession {
1649 cwd,
1650 messages: saved.messages,
1651 config: self.config.clone(),
1652 model: self.model.clone(),
1653 tool_registry,
1654 },
1655 );
1656 Ok(self.session_configuration(&resolved_id))
1657 }
1658
1659 fn session_models(session: &AcpSession) -> Vec<String> {
1660 let provider = session.config.api_provider();
1661 let mut models =
1662 crate::provider_lake::models_for_provider(&session.config, provider, provider);
1663 if !models.contains(&session.model) {
1664 models.push(session.model.clone());
1665 }
1666 models
1667 }
1668
1669 fn session_configuration(&self, session_id: &str) -> Value {
1670 use codewhale_localization::{MessageId, resolve_locale, tr};
1671 let settings = crate::settings::Settings::load().unwrap_or_default();
1672 let locale = resolve_locale(&settings.locale);
1673 let session = &self.sessions[session_id];
1674 let models = Self::session_models(session);
1675 let mut modes = vec![
1676 json!({"id": "plan", "name": tr(locale, MessageId::AppModePlan), "description": tr(locale, MessageId::AppModePlanHint)}),
1677 ];
1678 if acp_mode(&self.config) != AppMode::Plan {
1679 modes.insert(0, json!({"id": "agent", "name": tr(locale, MessageId::AppModeAgent), "description": tr(locale, MessageId::AppModeAgentHint)}));
1680 }
1681 let current_mode = if acp_mode(&session.config) == AppMode::Plan {
1682 "plan"
1683 } else {
1684 "agent"
1685 };
1686 json!({
1687 "sessionId": session_id,
1688 "modes": {"currentModeId": current_mode, "availableModes": modes},
1689 "models": {
1690 "currentModelId": session.model,
1691 "availableModels": models.iter().map(|model| json!({"modelId": model, "name": model})).collect::<Vec<_>>()
1692 },
1693 "configOptions": [
1694 {"id": "mode", "name": tr(locale, MessageId::SettingSubjectMode), "category": "mode", "type": "select", "currentValue": current_mode,
1695 "options": modes.iter().map(|mode| json!({"value": mode["id"], "name": mode["name"], "description": mode["description"]})).collect::<Vec<_>>()},
1696 {"id": "model", "name": tr(locale, MessageId::SettingSubjectModel), "category": "model", "type": "select", "currentValue": session.model,
1697 "options": models.iter().map(|model| json!({"value": model, "name": model})).collect::<Vec<_>>()}
1698 ]
1699 })
1700 }
1701
1702 fn set_session_config(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
1703 let session_id = params
1704 .get("sessionId")
1705 .and_then(Value::as_str)
1706 .ok_or_else(|| AcpError::invalid_params("sessionId is required"))?;
1707 let config_id = params
1708 .get("configId")
1709 .and_then(Value::as_str)
1710 .ok_or_else(|| AcpError::invalid_params("configId is required"))?;
1711 let value = params
1712 .get("value")
1713 .and_then(Value::as_str)
1714 .ok_or_else(|| AcpError::invalid_params("value must be an offered string option"))?;
1715 if !self.sessions.contains_key(session_id) {
1716 return Err(AcpError::invalid_params("unknown sessionId"));
1717 }
1718 let state = self.session_configuration(session_id);
1719 let offered = state["configOptions"]
1720 .as_array()
1721 .unwrap()
1722 .iter()
1723 .any(|option| {
1724 option["id"] == config_id
1725 && option["options"]
1726 .as_array()
1727 .unwrap()
1728 .iter()
1729 .any(|choice| choice["value"] == value)
1730 });
1731 if !offered {
1732 return Err(AcpError::invalid_params(
1733 "unknown configuration option or value",
1734 ));
1735 }
1736 let session = self.sessions.get_mut(session_id).unwrap();
1737 match config_id {
1738 "model" => session.model = value.to_string(),
1739 "mode" => {
1740 session.config.sandbox_mode = if value == "plan" {
1741 Some("read-only".to_string())
1742 } else {
1743 self.config.sandbox_mode.clone()
1744 };
1745 // Rebuild at this explicit between-turn authority boundary.
1746 // Both the next prompt prefix and tools reflect the new mode;
1747 // an in-flight turn keeps its frozen prefix and rejects setters.
1748 session.tool_registry = Arc::new(build_acp_tool_registry(
1749 &session.config,
1750 &session.cwd,
1751 self.client_supports_terminal,
1752 ));
1753 }
1754 _ => unreachable!("validated offered option"),
1755 }
1756 Ok(json!({"configOptions": self.session_configuration(session_id)["configOptions"]}))
1757 }
1758
1759 fn session_manager() -> Option<crate::session_manager::SessionManager> {
1760 let dir = crate::session_manager::default_sessions_dir().ok()?;
1761 crate::session_manager::SessionManager::new(dir).ok()
1762 }
1763
1764 fn session_tool_registry(&self, session_id: &str) -> Option<Arc<ToolRegistry>> {
1765 self.sessions
1766 .get(session_id)
1767 .map(|session| session.tool_registry.clone())
1768 }
1769
1770 fn list_providers(&self) -> Value {
1771 let mut providers = ApiProvider::sorted_for_display()
1772 .into_iter()
1773 .map(|provider| {
1774 json!({
1775 "id": provider.as_str(),
1776 "displayName": provider.display_name(),
1777 "defaultModel": provider.metadata().map(|metadata| metadata.default_model())
1778 })
1779 })
1780 .collect::<Vec<_>>();
1781
1782 // Include user-defined `[providers.<name>]` custom entries so ACP
1783 // clients can discover and round-trip the provider names that
1784 // `session/selectModel` now accepts (#1519).
1785 if let Some(custom) = self.config.providers.as_ref().map(|p| &p.custom) {
1786 let mut names = custom.keys().collect::<Vec<_>>();
1787 names.sort();
1788 for name in names {
1789 providers.push(json!({
1790 "id": name,
1791 "displayName": name,
1792 "defaultModel": custom.get(name).and_then(|cfg| cfg.model.clone())
1793 }));
1794 }
1795 }
1796
1797 json!({ "providers": providers })
1798 }
1799
1800 fn current_model(&self) -> Value {
1801 // Prefer the raw configured provider key so a custom `[providers.<name>]`
1802 // entry round-trips through ACP instead of canonicalizing to "custom".
1803 let provider = match self.config.provider.as_deref() {
1804 Some(name) if !name.trim().is_empty() => name.to_string(),
1805 _ => self.config.api_provider().as_str().to_string(),
1806 };
1807 json!({
1808 "provider": provider,
1809 "model": self.model.as_str()
1810 })
1811 }
1812
1813 fn select_model(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
1814 let model = params
1815 .get("model")
1816 .and_then(Value::as_str)
1817 .ok_or_else(|| AcpError::invalid_params("model is required"))?
1818 .to_string();
1819
1820 if let Some(provider_value) = params.get("provider") {
1821 let provider_name = provider_value
1822 .as_str()
1823 .ok_or_else(|| AcpError::invalid_params("provider must be a string"))?;
1824 // Accept either a built-in provider id/alias or a user-defined
1825 // custom provider name that has a `[providers.<name>]` table. For
1826 // custom providers, preserve the raw key so routing can still find
1827 // the configured base URL / auth / model (#1519); canonicalizing to
1828 // "custom" would lose that table key.
1829 let is_custom = self
1830 .config
1831 .providers
1832 .as_ref()
1833 .and_then(|providers| providers.custom_provider_config(provider_name))
1834 .is_some();
1835 if !is_custom && ApiProvider::parse(provider_name).is_none() {
1836 return Err(AcpError::invalid_params(format!(
1837 "unknown provider: {provider_name}"
1838 )));
1839 }
1840 self.config.provider = Some(provider_name.to_string());
1841 }
1842
1843 self.model = model;
1844 Ok(self.current_model())
1845 }
1846
1847 /// Validate a `session/prompt` request and append the user turn to history,
1848 /// returning the cloned conversation for the (borrow-free) provider call.
1849 ///
1850 /// This is the `&mut self` half of a prompt turn; the streaming provider
1851 /// call lives in [`AcpServer::open_prompt_stream`] (which borrows `&self`
1852 /// only and returns a `'static` stream) so it can be raced against the
1853 /// reader for cancellation.
1854 fn begin_prompt(&mut self, params: Value) -> std::result::Result<PreparedPrompt, AcpError> {
1855 let session_id = params
1856 .get("sessionId")
1857 .and_then(Value::as_str)
1858 .ok_or_else(|| AcpError::invalid_params("sessionId is required"))?
1859 .to_string();
1860 let prompt = extract_prompt_text(params.get("prompt"))
1861 .filter(|text| !text.trim().is_empty())
1862 .ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
1863
1864 let (messages, cwd, config, model) = {
1865 let session = self
1866 .sessions
1867 .get_mut(&session_id)
1868 .ok_or_else(|| AcpError::invalid_params("unknown sessionId"))?;
1869 session.messages.push(Message {
1870 role: Role::User,
1871 content: vec![ContentBlock::Text {
1872 text: prompt,
1873 cache_control: None,
1874 }],
1875 });
1876 (
1877 session.messages.clone(),
1878 session.cwd.clone(),
1879 session.config.clone(),
1880 session.model.clone(),
1881 )
1882 };
1883
1884 Ok(PreparedPrompt {
1885 session_id,
1886 messages,
1887 cwd,
1888 config,
1889 model,
1890 })
1891 }
1892
1893 /// Commit the full message list produced by a completed turn into the
1894 /// session's history — the original history plus every assistant/tool-
1895 /// call/tool-result round the turn drove.
1896 ///
1897 /// Called on **all** outcomes: normal completion, max-rounds, AND cancel.
1898 /// (The caller strips dangling assistant tool_use blocks on cancel before
1899 /// committing, which produces a clean partial history the next prompt can
1900 /// continue from instead of leaving the pre-turn state untouched.)
1901 fn commit_turn_messages(&mut self, session_id: &str, messages: Vec<Message>) {
1902 if let Some(session) = self.sessions.get_mut(session_id) {
1903 session.messages = messages;
1904 }
1905 }
1906
1907 /// Remove the last user message from the session history. Used to unwind
1908 /// the `begin_prompt` push when the turn itself fails (e.g. provider
1909 /// stream error), so the next prompt doesn't start with two consecutive
1910 /// `user` messages.
1911 fn rollback_user_message(&mut self, session_id: &str) {
1912 if let Some(session) = self.sessions.get_mut(session_id)
1913 && session.messages.last().map(|m| m.role.as_str()) == Some("user")
1914 {
1915 session.messages.pop();
1916 }
1917 }
1918
1919 /// Resolve the route, build the streaming request, and open the provider
1920 /// response stream. Borrows `&self` only to read config/model; the returned
1921 /// [`StreamEventBox`] is `'static`, so the caller can race it against the
1922 /// reader without holding any borrow on the server. The cwd guard only needs
1923 /// to cover route resolution and client construction, not stream
1924 /// consumption, so it is dropped here.
1925 async fn open_prompt_stream(
1926 &self,
1927 config: &Config,
1928 selected_model: &str,
1929 messages: &[Message],
1930 cwd: &PathBuf,
1931 tool_registry: &ToolRegistry,
1932 frozen_system_prompt: &std::sync::Mutex<Option<SystemPrompt>>,
1933 ) -> Result<StreamEventBox> {
1934 let _cwd_guard = ScopedCurrentDir::new(cwd)?;
1935 let last_user_text = messages
1936 .iter()
1937 .rev()
1938 .find_map(|m| {
1939 if m.role == "user" {
1940 m.content.iter().find_map(|b| match b {
1941 ContentBlock::Text { text, .. } => Some(text.as_str()),
1942 _ => None,
1943 })
1944 } else {
1945 None
1946 }
1947 })
1948 .unwrap_or("");
1949 let route = crate::resolve_cli_auto_route(config, selected_model, last_user_text).await?;
1950 let execution_config = crate::config_for_cli_route(config, &route);
1951 let client = CodewhaleClient::new(&execution_config)?;
1952 let model = route.model;
1953 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
1954 let reasoning_effort = route
1955 .reasoning_effort
1956 .and_then(|effort| {
1957 effort.api_value_for_route(
1958 execution_config.api_provider(),
1959 &execution_config.active_route_base_url(),
1960 &model,
1961 )
1962 })
1963 .map(str::to_string);
1964
1965 let tools = tool_registry.to_api_tools();
1966 let (route_limits, image_input) =
1967 resolve_acp_route_facts(&execution_config, request_route.provider, &model);
1968 let system = frozen_acp_system_prompt(
1969 frozen_system_prompt,
1970 &execution_config,
1971 cwd,
1972 request_route.provider,
1973 &request_route.model,
1974 route_limits,
1975 );
1976
1977 let mut outbound_messages = messages.to_vec();
1978 crate::image_attach::strip_images_when_unsupported(
1979 &mut outbound_messages,
1980 image_input,
1981 &request_route.model,
1982 );
1983 let request = MessageRequest {
1984 model,
1985 messages: outbound_messages,
1986 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
1987 request_route.provider,
1988 &request_route.model,
1989 route_limits,
1990 ),
1991 system: Some(system),
1992 tools: Some(tools.clone()),
1993 tool_choice: if tools.is_empty() {
1994 None
1995 } else {
1996 Some(json!({ "type": "auto" }))
1997 },
1998 metadata: None,
1999 thinking: None,
2000 reasoning_effort,
2001 stream: Some(true),
2002 temperature: None,
2003 top_p: None,
2004 };
2005
2006 client.create_message_stream(request).await
2007 }
2008 }
2009
2010 fn resolve_acp_route_facts(
2011 config: &Config,
2012 provider: ApiProvider,
2013 model: &str,
2014 ) -> (
2015 Option<codewhale_config::route::RouteLimits>,
2016 crate::model_profile::SupportState,
2017 ) {
2018 let Ok(route) = crate::route_runtime::resolve_runtime_route(config, provider, Some(model))
2019 else {
2020 return (None, crate::model_profile::SupportState::Unknown);
2021 };
2022 (
2023 crate::route_budget::known_route_limits(route.candidate.limits()),
2024 route.candidate.capabilities().image_input,
2025 )
2026 }
2027
2028 /// Return the first fully composed ACP system prompt for this user turn.
2029 /// Later tool rounds clone that exact value instead of re-reading mutable
2030 /// instruction sources from disk.
2031 fn frozen_acp_system_prompt(
2032 slot: &std::sync::Mutex<Option<SystemPrompt>>,
2033 config: &Config,
2034 workspace: &std::path::Path,
2035 provider: ApiProvider,
2036 model: &str,
2037 route_limits: Option<codewhale_config::route::RouteLimits>,
2038 ) -> SystemPrompt {
2039 let mut slot = match slot.lock() {
2040 Ok(slot) => slot,
2041 Err(poisoned) => poisoned.into_inner(),
2042 };
2043 if let Some(system) = slot.as_ref() {
2044 return system.clone();
2045 }
2046 let system = build_acp_system_prompt(config, workspace, provider, model, route_limits);
2047 *slot = Some(system.clone());
2048 system
2049 }
2050
2051 /// Compose ACP's stable prompt through the same headless host seam as
2052 /// `codewhale exec`. Tool availability remains owned by the request catalog;
2053 /// this function supplies the shared constitution, project instructions,
2054 /// configured instruction files, memory, locale, and route context.
2055 fn build_acp_system_prompt(
2056 config: &Config,
2057 workspace: &std::path::Path,
2058 provider: ApiProvider,
2059 model: &str,
2060 route_limits: Option<codewhale_config::route::RouteLimits>,
2061 ) -> SystemPrompt {
2062 let settings = crate::settings::Settings::load().unwrap_or_default();
2063 let locale_tag = codewhale_localization::resolve_locale(&settings.locale)
2064 .tag()
2065 .to_string();
2066 let instructions = config
2067 .instructions_paths()
2068 .into_iter()
2069 .map(crate::prompts::InstructionSource::from)
2070 .collect::<Vec<_>>();
2071 let skills_dir = config.skills_dir();
2072 let user_memory_block = crate::native_memory::native_prompt_block(
2073 config.memory_enabled(),
2074 &config.memory_path(),
2075 workspace,
2076 );
2077
2078 crate::prompts::system_prompt_for_mode_with_context_skills_session_and_approval_for_host(
2079 workspace,
2080 None,
2081 Some(&skills_dir),
2082 Some(&instructions),
2083 crate::prompts::PromptSessionContext {
2084 user_memory_block: user_memory_block.as_deref(),
2085 goal_objective: None,
2086 project_context_pack_enabled: config.project_context_pack_enabled(),
2087 locale_tag: &locale_tag,
2088 translation_enabled: false,
2089 model_id: model,
2090 context_window_override: Some(crate::route_budget::route_context_window_tokens(
2091 provider,
2092 model,
2093 route_limits,
2094 )),
2095 verbosity: config.verbosity.as_deref(),
2096 skills_scan_codewhale_only: config.skills_config().scan_codewhale_only(),
2097 plugin_registry: None,
2098 recovery_hint: None,
2099 mode: acp_mode(config),
2100 },
2101 crate::prompts::PromptHost::Headless,
2102 )
2103 }
2104
2105 fn acp_mode(config: &Config) -> AppMode {
2106 if config.sandbox_mode.as_deref() == Some("read-only") {
2107 AppMode::Plan
2108 } else {
2109 AppMode::Agent
2110 }
2111 }
2112
2113 /// Approval posture for ACP turns, derived from server config instead of
2114 /// hardcoded: `--yolo` resolves to Bypass so an unattended headless session
2115 /// actually executes tools (#6337); otherwise the configured approval policy,
2116 /// else the Suggest default. Plan mode still pins read-only downstream
2117 /// regardless of posture.
2118 fn acp_approval_mode(config: &Config) -> ApprovalMode {
2119 if config.yolo.unwrap_or(false) {
2120 ApprovalMode::Bypass
2121 } else {
2122 config
2123 .approval_policy
2124 .as_deref()
2125 .and_then(ApprovalMode::from_config_value)
2126 .unwrap_or_default()
2127 }
2128 }
2129
2130 /// Build the tool registry for one ACP session, rooted at the session's
2131 /// `cwd`. Reuses the shared registry builders used by headless `exec` and the
2132 /// MCP adapter — no ACP-specific tool implementations.
2133 ///
2134 /// Outside Plan mode, `Bash` is registered only when all three gates allow it: the
2135 /// client declares `clientCapabilities.terminal`, headless shell access is
2136 /// explicitly enabled in config, and the stable shell feature is enabled.
2137 /// Omitting any gate fails closed. The context also inherits the current
2138 /// mode-derived/configured sandbox boundary.
2139 /// `ToolContext::new` leaves `auto_approve` at its default (`false`), so the
2140 /// shell's own last-line safety check remains active after ACP's shared
2141 /// prepared-call, typed-policy, auto-review, repository-law, and explicit
2142 /// `session/request_permission` gates have admitted the call.
2143 fn build_acp_tool_registry(
2144 config: &Config,
2145 workspace: &std::path::Path,
2146 client_supports_terminal: bool,
2147 ) -> ToolRegistry {
2148 let features = config.features();
2149 let external_sandbox_requested = config.sandbox_backend.as_deref().is_some_and(|kind| {
2150 let kind = kind.trim();
2151 !kind.is_empty() && !kind.eq_ignore_ascii_case("none")
2152 });
2153 let sandbox_backend = match crate::sandbox::backend::create_backend(config) {
2154 Ok(backend) => backend
2155 .filter(|backend| backend.kind() != crate::sandbox::backend::SandboxKind::Unsupported)
2156 .map(std::sync::Arc::from),
2157 Err(error) => {
2158 tracing::warn!("Failed to create ACP sandbox backend: {error}");
2159 None
2160 }
2161 };
2162 // A requested external sandbox is an execution boundary, not a hint. If
2163 // it cannot be constructed, omit Bash instead of silently running the
2164 // command on the local host.
2165 let sandbox_backend_ready = !external_sandbox_requested || sandbox_backend.is_some();
2166 let allow_shell = acp_mode(config) != AppMode::Plan
2167 && client_supports_terminal
2168 && config.allow_shell()
2169 && features.enabled(crate::features::Feature::ShellTool)
2170 && sandbox_backend_ready;
2171 let shell_policy = if allow_shell {
2172 ShellPolicy::Full
2173 } else {
2174 ShellPolicy::None
2175 };
2176 let sandbox_policy = crate::core::authority::sandbox_policy_for_turn(
2177 acp_mode(config),
2178 acp_approval_mode(config),
2179 config.sandbox_mode.as_deref(),
2180 workspace,
2181 crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access),
2182 );
2183 let mut context = ToolContext::new(workspace)
2184 .with_shell_policy(shell_policy)
2185 .with_elevated_sandbox_policy(sandbox_policy);
2186 if acp_mode(config) == AppMode::Plan {
2187 // Use the shared headless authority cap for file/Git dispatch too:
2188 // an OS shell sandbox alone cannot prevent in-process tool writes.
2189 context.tool_authority = Some(Arc::new(crate::tools::spec::ToolAuthorityEnvelope {
2190 schema_version: 1,
2191 owner: "acp-plan".to_string(),
2192 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
2193 network_access: Some(false),
2194 shell: crate::tools::spec::ToolShellAuthority::None,
2195 verification: crate::tools::spec::ToolVerificationAuthority::None,
2196 writable_roots: Vec::new(),
2197 writable_files: Vec::new(),
2198 coordination_contracts: Vec::new(),
2199 }));
2200 }
2201 match context.shell_manager.lock() {
2202 Ok(mut manager) => manager.set_prefer_bwrap(config.prefer_bwrap.unwrap_or(false)),
2203 Err(poisoned) => poisoned
2204 .into_inner()
2205 .set_prefer_bwrap(config.prefer_bwrap.unwrap_or(false)),
2206 }
2207 if let Some(backend) = sandbox_backend {
2208 context = context.with_sandbox_backend(backend);
2209 }
2210 let hooks_config =
2211 crate::hooks::HooksConfig::load_with_project(config.hooks_config(), workspace);
2212 context.runtime.hook_executor = Some(Arc::new(crate::hooks::HookExecutor::new(
2213 hooks_config,
2214 workspace.to_path_buf(),
2215 )));
2216
2217 let mut builder = ToolRegistryBuilder::new()
2218 .with_file_tools()
2219 .with_search_tools()
2220 .with_git_tools();
2221 if features.enabled(crate::features::Feature::ApplyPatch) {
2222 builder = builder.with_patch_tools();
2223 }
2224 if allow_shell {
2225 builder = builder.with_foreground_shell_tools();
2226 }
2227
2228 let mut registry = builder.build(context);
2229 // ACP does not load arbitrary plugin replacements in v0.9.6, but it must
2230 // never fall through to a built-in the operator disabled or replaced.
2231 if let Some(overrides) = config
2232 .tools
2233 .as_ref()
2234 .and_then(|tools| tools.overrides.as_ref())
2235 {
2236 for tool_name in overrides.keys() {
2237 remove_acp_overridden_builtin(&mut registry, tool_name);
2238 }
2239 }
2240 registry
2241 }
2242
2243 /// ACP does not load executable tool replacements in v0.9.6. Remove the
2244 /// built-in compatibility family for every configured override so neither a
2245 /// hidden legacy alias nor a newly canonical lowercase name can fall through
2246 /// to the original implementation.
2247 fn remove_acp_overridden_builtin(registry: &mut ToolRegistry, tool_name: &str) {
2248 let aliases: &[&str] = match tool_name {
2249 "bash" | "Bash" | "exec_shell" => &["bash", "Bash", "exec_shell"],
2250 "read" | "write" | "edit" | "File" | "read_file" | "write_file" | "edit_file" => &[
2251 "read",
2252 "write",
2253 "edit",
2254 "File",
2255 "read_file",
2256 "write_file",
2257 "edit_file",
2258 ],
2259 "apply_patch" => &["apply_patch"],
2260 _ => std::slice::from_ref(&tool_name),
2261 };
2262 for alias in aliases {
2263 registry.remove_tool(alias);
2264 }
2265 }
2266
2267 /// ACP `kind` hint for a tool call, used by the client to pick an icon/label.
2268 /// Falls back to `"other"` for tools without an obvious category.
2269 ///
2270 /// `File` is a single canonical tool covering read/list/search/write/edit/
2271 /// patch (#4625), so its kind depends on the `action` argument rather than
2272 /// the tool name alone.
2273 fn tool_call_kind(call: &PendingToolCall) -> &'static str {
2274 match call.name.as_str() {
2275 "File" => match call.input.get("action").and_then(Value::as_str) {
2276 Some("write" | "edit" | "patch") => "edit",
2277 _ => "read",
2278 },
2279 "apply_patch" => "edit",
2280 "Git" => "read",
2281 "bash" | "Bash" | "terminal/run" | "terminal/send" | "terminal/wait"
2282 | "terminal/cancel" | "terminal/reset" => "execute",
2283 _ => "other",
2284 }
2285 }
2286
2287 /// Human-readable title for a tool call: the tool name plus its primary
2288 /// argument (path/command/pattern) when present, so the client's tool-call
2289 /// card is legible without expanding raw input.
2290 fn tool_call_title(call: &PendingToolCall) -> String {
2291 let detail = call
2292 .input
2293 .get("path")
2294 .or_else(|| call.input.get("command"))
2295 .or_else(|| call.input.get("pattern"))
2296 .or_else(|| call.input.get("task_id"))
2297 .and_then(Value::as_str);
2298 match detail {
2299 Some(detail) => format!("{}: {}", call.name, detail),
2300 None => call.name.clone(),
2301 }
2302 }
2303
2304 fn truncate_for_acp(content: &str) -> String {
2305 if content.chars().count() <= TOOL_CALL_CONTENT_PREVIEW_CHARS {
2306 return content.to_string();
2307 }
2308 let truncated: String = content
2309 .chars()
2310 .take(TOOL_CALL_CONTENT_PREVIEW_CHARS)
2311 .collect();
2312 format!("{truncated}\n… [truncated for display; the full result was sent to the model]")
2313 }
2314
2315 async fn write_tool_call_start<W>(
2316 writer: &mut W,
2317 session_id: &str,
2318 call: &PendingToolCall,
2319 ) -> Result<()>
2320 where
2321 W: AsyncWrite + Unpin,
2322 {
2323 let notification = json!({
2324 "jsonrpc": "2.0",
2325 "method": "session/update",
2326 "params": {
2327 "sessionId": session_id,
2328 "update": {
2329 "sessionUpdate": "tool_call",
2330 "toolCallId": call.id,
2331 "title": tool_call_title(call),
2332 "kind": tool_call_kind(call),
2333 "status": "pending",
2334 "rawInput": call.input,
2335 }
2336 }
2337 });
2338 write_json_line(writer, notification).await
2339 }
2340
2341 async fn write_tool_call_update<W>(
2342 writer: &mut W,
2343 session_id: &str,
2344 call: &PendingToolCall,
2345 status: &str,
2346 content: Option<&str>,
2347 ) -> Result<()>
2348 where
2349 W: AsyncWrite + Unpin,
2350 {
2351 write_tool_call_update_with_blocks(writer, session_id, call, status, content, &[]).await
2352 }
2353
2354 async fn write_tool_call_update_with_blocks<W>(
2355 writer: &mut W,
2356 session_id: &str,
2357 call: &PendingToolCall,
2358 status: &str,
2359 content: Option<&str>,
2360 rich_blocks: &[codewhale_tools::ToolResultContentBlock],
2361 ) -> Result<()>
2362 where
2363 W: AsyncWrite + Unpin,
2364 {
2365 let mut update = json!({
2366 "sessionUpdate": "tool_call_update",
2367 "toolCallId": call.id,
2368 "status": status,
2369 });
2370 if content.is_some() || !rich_blocks.is_empty() {
2371 let mut blocks = Vec::with_capacity(rich_blocks.len() + usize::from(content.is_some()));
2372 if let Some(content) = content {
2373 blocks.push(json!({
2374 "type": "content",
2375 "content": { "type": "text", "text": truncate_for_acp(content) }
2376 }));
2377 }
2378 blocks.extend(rich_blocks.iter().map(|block| match block {
2379 codewhale_tools::ToolResultContentBlock::Image { mime_type, data } => json!({
2380 "type": "content",
2381 "content": { "type": "image", "data": data, "mimeType": mime_type }
2382 }),
2383 }));
2384 update["content"] = json!(blocks);
2385 }
2386 let notification = json!({
2387 "jsonrpc": "2.0",
2388 "method": "session/update",
2389 "params": {
2390 "sessionId": session_id,
2391 "update": update
2392 }
2393 });
2394 write_json_line(writer, notification).await
2395 }
2396
2397 struct ScopedCurrentDir {
2398 prior: PathBuf,
2399 }
2400
2401 impl ScopedCurrentDir {
2402 fn new(cwd: &PathBuf) -> Result<Self> {
2403 let prior = std::env::current_dir()?;
2404 if cwd.as_os_str().is_empty() {
2405 return Ok(Self { prior });
2406 }
2407 std::env::set_current_dir(cwd)
2408 .map_err(|err| anyhow!("failed to enter ACP session cwd {}: {err}", cwd.display()))?;
2409 Ok(Self { prior })
2410 }
2411 }
2412
2413 impl Drop for ScopedCurrentDir {
2414 fn drop(&mut self) {
2415 let _ = std::env::set_current_dir(&self.prior);
2416 }
2417 }
2418
2419 impl AcpError {
2420 fn invalid_params(message: impl Into<String>) -> Self {
2421 Self {
2422 code: -32602,
2423 message: message.into(),
2424 }
2425 }
2426
2427 /// JSON-RPC internal error: the request was well-formed and the agent
2428 /// could not serve it.
2429 fn internal(message: impl Into<String>) -> Self {
2430 Self {
2431 code: -32603,
2432 message: message.into(),
2433 }
2434 }
2435
2436 fn method_not_found(method: &str) -> Self {
2437 Self {
2438 code: -32601,
2439 message: format!("method not found: {method}"),
2440 }
2441 }
2442 }
2443
2444 fn initialize_result(client_protocol_version: Option<u64>, config: &Config) -> Value {
2445 json!({
2446 "protocolVersion": client_protocol_version
2447 .map(|version| version.min(ACP_PROTOCOL_VERSION))
2448 .unwrap_or(ACP_PROTOCOL_VERSION),
2449 "agentCapabilities": {
2450 "loadSession": true,
2451 "modelSelection": true,
2452 "promptCapabilities": {
2453 "image": false,
2454 "audio": false,
2455 "embeddedContext": true
2456 },
2457 "mcpCapabilities": {
2458 "http": false,
2459 "sse": false
2460 },
2461 // ACP `SessionCapabilities` fields are objects, never booleans:
2462 // `{}` means "supported", absent/null means "not supported"
2463 // (#5969 — a boolean here made JetBrains' strictly-typed client
2464 // fail the handshake and kill the agent). `session/load` support
2465 // is advertised by the top-level `loadSession` above; it is not a
2466 // field of `sessionCapabilities`. We only claim `list` because
2467 // `session/list` is the only one of the optional session methods
2468 // this server dispatches.
2469 "sessionCapabilities": {
2470 "list": {}
2471 }
2472 },
2473 "agentInfo": {
2474 "name": "codewhale",
2475 "title": "codewhale",
2476 "version": env!("CARGO_PKG_VERSION")
2477 },
2478 "authMethods": acp_auth_methods(config)
2479 })
2480 }
2481
2482 fn acp_auth_methods(config: &Config) -> Value {
2483 let provider = config.api_provider().as_str();
2484 json!([
2485 {
2486 "id": "codewhale-terminal-auth",
2487 "name": "Set Codewhale API key",
2488 "description": format!("Run Codewhale's terminal credential setup for the {provider} provider."),
2489 "type": "terminal",
2490 "args": ["auth", "set", "--provider", provider],
2491 "env": {}
2492 }
2493 ])
2494 }
2495
2496 fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
2497 match prompt? {
2498 Value::String(text) => Some(text.clone()),
2499 Value::Array(blocks) => {
2500 let parts = blocks
2501 .iter()
2502 .filter_map(content_block_text)
2503 .collect::<Vec<_>>();
2504 (!parts.is_empty()).then(|| parts.join("\n\n"))
2505 }
2506 _ => None,
2507 }
2508 }
2509
2510 fn content_block_text(block: &Value) -> Option<String> {
2511 match block.get("type").and_then(Value::as_str)? {
2512 "text" => block
2513 .get("text")
2514 .and_then(Value::as_str)
2515 .map(str::to_string),
2516 "resource" => resource_text(block),
2517 "resource_link" | "resourceLink" => resource_link_text(block),
2518 _ => None,
2519 }
2520 }
2521
2522 fn resource_text(block: &Value) -> Option<String> {
2523 let resource = block.get("resource").unwrap_or(block);
2524 if let Some(text) = resource.get("text").and_then(Value::as_str) {
2525 return Some(text.to_string());
2526 }
2527 resource_link_text(resource)
2528 }
2529
2530 fn resource_link_text(block: &Value) -> Option<String> {
2531 let uri = block
2532 .get("uri")
2533 .or_else(|| block.pointer("/resource/uri"))
2534 .and_then(Value::as_str)?;
2535 Some(format!("@{uri}"))
2536 }
2537
2538 async fn write_session_update<W>(writer: &mut W, session_id: &str, text: String) -> Result<()>
2539 where
2540 W: AsyncWrite + Unpin,
2541 {
2542 let notification = json!({
2543 "jsonrpc": "2.0",
2544 "method": "session/update",
2545 "params": {
2546 "sessionId": session_id,
2547 "update": {
2548 "sessionUpdate": "agent_message_chunk",
2549 "content": {
2550 "type": "text",
2551 "text": text
2552 }
2553 }
2554 }
2555 });
2556 write_json_line(writer, notification).await
2557 }
2558
2559 async fn write_jsonrpc_result<W>(writer: &mut W, id: Value, result: Value) -> Result<()>
2560 where
2561 W: AsyncWrite + Unpin,
2562 {
2563 write_json_line(
2564 writer,
2565 json!({
2566 "jsonrpc": "2.0",
2567 "id": id,
2568 "result": result
2569 }),
2570 )
2571 .await
2572 }
2573
2574 async fn write_jsonrpc_error<W>(
2575 writer: &mut W,
2576 id: Option<Value>,
2577 code: i32,
2578 message: impl Into<String>,
2579 ) -> Result<()>
2580 where
2581 W: AsyncWrite + Unpin,
2582 {
2583 write_json_line(
2584 writer,
2585 json!({
2586 "jsonrpc": "2.0",
2587 "id": id,
2588 "error": {
2589 "code": code,
2590 "message": message.into()
2591 }
2592 }),
2593 )
2594 .await
2595 }
2596
2597 async fn write_json_line<W>(writer: &mut W, value: Value) -> Result<()>
2598 where
2599 W: AsyncWrite + Unpin,
2600 {
2601 writer.write_all(value.to_string().as_bytes()).await?;
2602 writer.write_all(b"\n").await?;
2603 writer.flush().await?;
2604 Ok(())
2605 }
2606
2607 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2608 enum JsonRpcResponseIdPolicy {
2609 /// JSON-RPC's normal contract: echo the request id without changing type.
2610 Preserve,
2611 /// Zed's ACP client currently decodes response ids as strings even when it
2612 /// sent a number. Keep this narrow compatibility mode client-identified.
2613 StringifyNumeric,
2614 }
2615
2616 impl JsonRpcResponseIdPolicy {
2617 fn from_initialize_params(params: &Value) -> Self {
2618 let client_name = params
2619 .pointer("/clientInfo/name")
2620 .and_then(Value::as_str)
2621 .unwrap_or_default();
2622 if client_name.eq_ignore_ascii_case("zed") {
2623 Self::StringifyNumeric
2624 } else {
2625 Self::Preserve
2626 }
2627 }
2628
2629 fn response_id(self, id: Value) -> Value {
2630 match (self, id) {
2631 (Self::StringifyNumeric, Value::Number(number)) => Value::String(number.to_string()),
2632 (_, id) => id,
2633 }
2634 }
2635 }
2636
2637 #[cfg(test)]
2638 mod tests {
2639 use super::*;
2640 use std::cell::RefCell;
2641 use std::collections::VecDeque;
2642
2643 #[tokio::test]
2644 async fn tool_update_emits_typed_acp_image_content() {
2645 let mut output = Vec::new();
2646 let call = PendingToolCall {
2647 id: "call_image_1".to_string(),
2648 name: "read".to_string(),
2649 input: json!({"path": "shot.png"}),
2650 parse_error: None,
2651 };
2652 write_tool_call_update_with_blocks(
2653 &mut output,
2654 "session_1",
2655 &call,
2656 "completed",
2657 Some("screenshot captured"),
2658 &[codewhale_tools::ToolResultContentBlock::Image {
2659 mime_type: "image/png".to_string(),
2660 data: "QUJD".to_string(),
2661 }],
2662 )
2663 .await
2664 .expect("ACP update");
2665
2666 let lines = parse_lines(output);
2667 let content = lines[0]["params"]["update"]["content"]
2668 .as_array()
2669 .expect("ACP content blocks");
2670 assert_eq!(content[0]["content"]["type"], "text");
2671 assert_eq!(content[1]["content"]["type"], "image");
2672 assert_eq!(content[1]["content"]["mimeType"], "image/png");
2673 assert_eq!(content[1]["content"]["data"], "QUJD");
2674 }
2675
2676 /// #5864: `serve --acp` implemented `initialize` and `session/new` and
2677 /// nothing else, so ACP clients that offer session history could not
2678 /// enumerate or resume anything. ACP sessions are in-memory and capped;
2679 /// the durable Codewhale sessions are what "resume" means.
2680 #[tokio::test]
2681 async fn session_list_and_load_reach_the_durable_codewhale_sessions() {
2682 let _guard = crate::test_support::lock_test_env();
2683 let home = tempfile::TempDir::new().expect("isolated codewhale home");
2684 let _home_guard =
2685 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str());
2686
2687 let workspace = home.path().join("workspace");
2688 std::fs::create_dir_all(&workspace).expect("workspace");
2689 let saved = crate::session_manager::create_saved_session(
2690 &[Message {
2691 role: Role::User,
2692 content: vec![ContentBlock::Text {
2693 text: "what did we decide?".to_string(),
2694 cache_control: None,
2695 }],
2696 }],
2697 "deepseek-v4-flash",
2698 &workspace,
2699 42,
2700 None,
2701 );
2702 let saved_id = saved.metadata.id.clone();
2703 let manager = crate::session_manager::SessionManager::new(
2704 crate::session_manager::default_sessions_dir().expect("sessions dir"),
2705 )
2706 .expect("session manager");
2707 manager.save_session(&saved).expect("save fixture session");
2708
2709 let mut server = AcpServer::new(
2710 Config::default(),
2711 "deepseek-v4-flash".to_string(),
2712 workspace.clone(),
2713 );
2714
2715 let listed = server.list_sessions(json!({})).expect("session/list");
2716 let ids: Vec<&str> = listed["sessions"]
2717 .as_array()
2718 .expect("sessions array")
2719 .iter()
2720 .filter_map(|entry| entry["sessionId"].as_str())
2721 .collect();
2722 assert!(
2723 ids.contains(&saved_id.as_str()),
2724 "session/list must enumerate durable sessions: {ids:?}"
2725 );
2726
2727 let other_workspace = home.path().join("other-workspace");
2728 std::fs::create_dir_all(&other_workspace).unwrap();
2729 let other = crate::session_manager::create_saved_session(
2730 &saved.messages,
2731 "deepseek-v4-flash",
2732 &other_workspace,
2733 0,
2734 None,
2735 );
2736 manager.save_session(&other).unwrap();
2737 for filter in [workspace.clone(), workspace.join(".")] {
2738 let AcpDispatch::Response(filtered) = server
2739 .handle_request("session/list", json!({"cwd": filter}))
2740 .await
2741 .expect("filtered session/list")
2742 else {
2743 panic!("session/list returned shutdown");
2744 };
2745 let entries = filtered["sessions"].as_array().unwrap();
2746 assert_eq!(
2747 entries.len(),
2748 1,
2749 "workspace filter must not include another directory"
2750 );
2751 assert_eq!(entries[0]["sessionId"], saved_id);
2752 }
2753 let AcpDispatch::Response(all) = server
2754 .handle_request("session/list", json!({}))
2755 .await
2756 .unwrap()
2757 else {
2758 panic!("session/list returned shutdown");
2759 };
2760 assert_eq!(all["sessions"].as_array().unwrap().len(), 2);
2761 let AcpDispatch::Response(empty) = server
2762 .handle_request(
2763 "session/list",
2764 json!({"cwd": home.path().join("missing-workspace")}),
2765 )
2766 .await
2767 .unwrap()
2768 else {
2769 panic!("session/list returned shutdown");
2770 };
2771 assert!(empty["sessions"].as_array().unwrap().is_empty());
2772 for invalid in [json!("relative"), json!(""), json!(42)] {
2773 let error = server
2774 .handle_request("session/list", json!({"cwd": invalid}))
2775 .await
2776 .err()
2777 .expect("invalid cwd must be rejected");
2778 assert_eq!(error.code, -32602);
2779 }
2780
2781 let loaded = server
2782 .load_session(json!({ "sessionId": saved_id }))
2783 .expect("session/load");
2784 assert_eq!(loaded["sessionId"], saved_id);
2785 assert!(
2786 loaded["configOptions"]
2787 .as_array()
2788 .is_some_and(|options| options.len() == 2)
2789 );
2790 let session = server
2791 .sessions
2792 .get(&saved_id)
2793 .expect("loaded session is addressable by its own id");
2794 assert_eq!(session.cwd, workspace, "cwd comes from the saved workspace");
2795 assert_eq!(
2796 session.messages.len(),
2797 1,
2798 "the conversation is rehydrated, not started empty"
2799 );
2800
2801 // A session that does not exist is a client error, not a panic.
2802 let missing = server.load_session(json!({ "sessionId": "codewhale-nope" }));
2803 assert_eq!(missing.expect_err("unknown session").code, -32602);
2804 let no_id = server.load_session(json!({}));
2805 assert_eq!(no_id.expect_err("missing sessionId").code, -32602);
2806 }
2807
2808 /// #6245: reloading a tracked session by a short prefix must not push a
2809 /// duplicate `insertion_order` entry. The duplicate made the deque
2810 /// disagree with `sessions`, so a later capacity eviction popped the
2811 /// stale front copy of a just-reloaded session and removed a live
2812 /// conversation.
2813 #[tokio::test]
2814 async fn loading_a_tracked_session_by_prefix_does_not_duplicate_ordering() {
2815 let _guard = crate::test_support::lock_test_env();
2816 let home = tempfile::TempDir::new().expect("isolated codewhale home");
2817 let _home_guard =
2818 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path().as_os_str());
2819
2820 let workspace = home.path().join("workspace");
2821 std::fs::create_dir_all(&workspace).expect("workspace");
2822 let saved = crate::session_manager::create_saved_session(
2823 &[Message {
2824 role: Role::User,
2825 content: vec![ContentBlock::Text {
2826 text: "reload me by prefix".to_string(),
2827 cache_control: None,
2828 }],
2829 }],
2830 "deepseek-v4-flash",
2831 &workspace,
2832 42,
2833 None,
2834 );
2835 let saved_id = saved.metadata.id.clone();
2836 let manager = crate::session_manager::SessionManager::new(
2837 crate::session_manager::default_sessions_dir().expect("sessions dir"),
2838 )
2839 .expect("session manager");
2840 manager.save_session(&saved).expect("save fixture session");
2841
2842 let mut server = AcpServer::new(
2843 Config::default(),
2844 "deepseek-v4-flash".to_string(),
2845 workspace.clone(),
2846 );
2847
2848 // Load by the full id first: the session becomes tracked exactly once.
2849 let loaded = server
2850 .load_session(json!({ "sessionId": saved_id }))
2851 .expect("load by full id");
2852 assert_eq!(loaded["sessionId"], saved_id);
2853
2854 // A prefix resolving to the same tracked id must be idempotent, not
2855 // a second insertion.
2856 let prefix: String = saved_id.chars().take(8).collect();
2857 let reloaded = server
2858 .load_session(json!({ "sessionId": prefix }))
2859 .expect("load by prefix");
2860 assert_eq!(reloaded["sessionId"], saved_id);
2861
2862 assert_eq!(server.sessions.len(), 1);
2863 assert_eq!(
2864 server.insertion_order.len(),
2865 server.sessions.len(),
2866 "a prefix reload of a tracked session must not duplicate the ordering entry"
2867 );
2868 assert_eq!(
2869 server
2870 .insertion_order
2871 .iter()
2872 .filter(|id| *id == &saved_id)
2873 .count(),
2874 1,
2875 "the ordering deque holds the tracked id exactly once"
2876 );
2877 }
2878
2879 #[tokio::test]
2880 async fn standard_session_configuration_is_offered_and_scoped_to_one_session() {
2881 let workspace = tempfile::tempdir().unwrap();
2882 let mut server = AcpServer::new(
2883 Config::default(),
2884 "deepseek-v4-flash".into(),
2885 workspace.path().into(),
2886 );
2887 let first = server.new_session(json!({})).unwrap();
2888 let second = server.new_session(json!({})).unwrap();
2889 let first_id = first["sessionId"].as_str().unwrap();
2890 let second_id = second["sessionId"].as_str().unwrap();
2891 assert_eq!(first["modes"]["currentModeId"], "agent");
2892 assert_eq!(first["models"]["currentModelId"], "deepseek-v4-flash");
2893 let alternative = first["models"]["availableModels"]
2894 .as_array()
2895 .unwrap()
2896 .iter()
2897 .find_map(|row| {
2898 row["modelId"]
2899 .as_str()
2900 .filter(|model| *model != "deepseek-v4-flash")
2901 })
2902 .expect("shared active-provider catalog offers another model");
2903
2904 server
2905 .handle_request(
2906 "session/set_model",
2907 json!({"sessionId": first_id, "modelId": alternative}),
2908 )
2909 .await
2910 .unwrap();
2911 let AcpDispatch::Response(configured) = server
2912 .handle_request(
2913 "session/set_config_option",
2914 json!({"sessionId": first_id, "configId": "mode", "value": "plan"}),
2915 )
2916 .await
2917 .unwrap()
2918 else {
2919 panic!("configuration response")
2920 };
2921 assert_eq!(configured["configOptions"].as_array().unwrap().len(), 2);
2922 assert_eq!(configured["configOptions"][0]["currentValue"], "plan");
2923 assert_eq!(configured["configOptions"][1]["currentValue"], alternative);
2924 assert_eq!(
2925 server.session_configuration(second_id),
2926 second,
2927 "another session is unchanged"
2928 );
2929 let prepared = server
2930 .begin_prompt(
2931 json!({"sessionId": first_id, "prompt": [{"type": "text", "text": "review this"}]}),
2932 )
2933 .unwrap();
2934 assert_eq!(
2935 prepared.model, alternative,
2936 "provider request receives the session model"
2937 );
2938 assert_eq!(acp_mode(&prepared.config), AppMode::Plan);
2939 server
2940 .handle_request(
2941 "session/set_mode",
2942 json!({"sessionId": first_id, "modeId": "agent"}),
2943 )
2944 .await
2945 .unwrap();
2946 assert_eq!(
2947 server.sessions[first_id].messages.len(),
2948 1,
2949 "configuration retains history"
2950 );
2951 assert_eq!(
2952 server.sessions[first_id].config.sandbox_mode,
2953 server.config.sandbox_mode
2954 );
2955 assert_eq!(
2956 server.model, "deepseek-v4-flash",
2957 "session setters do not alter defaults"
2958 );
2959 }
2960
2961 #[tokio::test]
2962 async fn plan_configuration_enforces_shared_read_only_tools() {
2963 let workspace = tempfile::tempdir().unwrap();
2964 let config = Config {
2965 allow_shell: Some(true),
2966 sandbox_mode: Some("danger-full-access".into()),
2967 ..Config::default()
2968 };
2969 let mut server =
2970 AcpServer::new(config, "deepseek-v4-flash".into(), workspace.path().into());
2971 server.client_supports_terminal = true;
2972 let new = server.new_session(json!({})).unwrap();
2973 let id = new["sessionId"].as_str().unwrap();
2974 server
2975 .set_session_config(json!({"sessionId": id, "configId": "mode", "value": "plan"}))
2976 .unwrap();
2977 let registry = server.session_tool_registry(id).unwrap();
2978 assert!(
2979 registry.get("Bash").is_none(),
2980 "Plan does not offer shell execution"
2981 );
2982 let target = workspace.path().join("must-not-exist.txt");
2983 assert!(
2984 registry.get("write").is_some(),
2985 "exercise the real shared file writer"
2986 );
2987 let outcome = registry
2988 .execute_full(
2989 "write",
2990 json!({"path": target, "content": "unauthorized write"}),
2991 )
2992 .await;
2993 assert!(
2994 matches!(outcome, Err(ToolError::PermissionDenied { .. })),
2995 "the shared authority must reject mutation: {outcome:?}"
2996 );
2997 assert!(!target.exists());
2998 }
2999
3000 #[test]
3001 fn acp_approval_mode_derives_from_server_config() {
3002 // #6337: `--yolo --danger-full-access` must not silently run as Ask.
3003 let yolo = Config {
3004 yolo: Some(true),
3005 ..Config::default()
3006 };
3007 assert_eq!(acp_approval_mode(&yolo), ApprovalMode::Bypass);
3008 let policy = Config {
3009 approval_policy: Some("never".into()),
3010 ..Config::default()
3011 };
3012 assert_eq!(acp_approval_mode(&policy), ApprovalMode::Never);
3013 assert_eq!(acp_approval_mode(&Config::default()), ApprovalMode::Suggest);
3014 }
3015
3016 #[test]
3017 fn yolo_admission_auto_executes_write_without_permission_round_trip() {
3018 // #6337: an unattended `--yolo` session must execute tools instead of
3019 // stalling on permission requests no headless client answers.
3020 let (dir, registry) = workspace_registry();
3021 let config = Config {
3022 yolo: Some(true),
3023 ..Config::default()
3024 };
3025 let call = pending_call(
3026 "File",
3027 json!({"action": "write", "path": "yolo.txt", "content": "yolo"}),
3028 );
3029 let (_, admission) = prepare_acp_tool_admission(&config, &registry, &call).unwrap();
3030 assert_eq!(admission, AcpToolAdmission::Auto);
3031 assert_eq!(registry.context().workspace, dir.path());
3032 }
3033
3034 #[test]
3035 fn default_admission_still_requests_permission_for_write() {
3036 // Pins the Ask default the yolo test above contrasts with: without
3037 // `--yolo`, a write surfaces a permission request to the client.
3038 let (_dir, registry) = workspace_registry();
3039 let call = pending_call(
3040 "File",
3041 json!({"action": "write", "path": "ask.txt", "content": "ask"}),
3042 );
3043 let (_, admission) =
3044 prepare_acp_tool_admission(&Config::default(), &registry, &call).unwrap();
3045 assert!(matches!(admission, AcpToolAdmission::RequestPermission(_)));
3046 }
3047
3048 #[tokio::test]
3049 async fn plan_mode_stays_read_only_under_yolo() {
3050 // The posture derivation must never loosen the Plan guardrail.
3051 let dir = tempfile::tempdir().expect("tempdir");
3052 let config = Config {
3053 yolo: Some(true),
3054 sandbox_mode: Some("read-only".into()),
3055 ..Config::default()
3056 };
3057 let registry = build_acp_tool_registry(&config, dir.path(), false);
3058 let target = dir.path().join("must-not-exist.txt");
3059 let outcome = registry
3060 .execute_full("write", json!({"path": target, "content": "x"}))
3061 .await;
3062 assert!(
3063 matches!(outcome, Err(ToolError::PermissionDenied { .. })),
3064 "Plan stays read-only under yolo: {outcome:?}"
3065 );
3066 assert!(!target.exists());
3067 }
3068
3069 #[test]
3070 fn session_configuration_cannot_relax_a_configured_floor_or_invent_values() {
3071 let workspace = tempfile::tempdir().unwrap();
3072 let config = Config {
3073 sandbox_mode: Some("read-only".into()),
3074 ..Config::default()
3075 };
3076 let mut server =
3077 AcpServer::new(config, "deepseek-v4-flash".into(), workspace.path().into());
3078 let before = server.new_session(json!({})).unwrap();
3079 let id = before["sessionId"].as_str().unwrap();
3080 assert_eq!(before["modes"]["currentModeId"], "plan");
3081 for params in [
3082 json!({"sessionId": id, "configId": "mode", "value": "agent"}),
3083 json!({"sessionId": id, "configId": "model", "value": "unknown-model"}),
3084 json!({"sessionId": id, "configId": "permission", "value": "bypass"}),
3085 json!({"sessionId": id, "configId": "mode", "value": true}),
3086 json!({"sessionId": "missing", "configId": "mode", "value": "plan"}),
3087 json!({"configId": "mode", "value": "plan"}),
3088 ] {
3089 assert_eq!(server.set_session_config(params).unwrap_err().code, -32602);
3090 assert_eq!(server.session_configuration(id), before);
3091 }
3092 }
3093
3094 #[test]
3095 fn initialize_advertises_baseline_acp_agent() {
3096 let result = initialize_result(Some(1), &Config::default());
3097
3098 assert_eq!(result["protocolVersion"], 1);
3099 assert_eq!(result["agentInfo"]["name"], "codewhale");
3100 // #5864: enumerating and resuming durable Codewhale sessions is now
3101 // served, so the capability says so rather than declining it.
3102 // #5969: this test used to assert `list == true` and `load == true`,
3103 // certifying the wire format that broke every strictly-typed client.
3104 // `loadSession` is the top-level boolean that advertises
3105 // `session/load`; inside `sessionCapabilities` every field is a
3106 // capability *object*, and `load` is not a field at all. Comparing the
3107 // whole object pins both halves: a boolean `list` or a resurrected
3108 // `load` key fails here.
3109 assert_eq!(result["agentCapabilities"]["loadSession"], true);
3110 assert_eq!(
3111 result["agentCapabilities"]["sessionCapabilities"],
3112 json!({"list": {}})
3113 );
3114 assert!(
3115 result["agentCapabilities"]["sessionCapabilities"]["list"].is_object(),
3116 "sessionCapabilities.list must be a SessionListCapabilities object, got {}",
3117 result["agentCapabilities"]["sessionCapabilities"]["list"]
3118 );
3119 assert_eq!(
3120 result["agentCapabilities"]["promptCapabilities"]["embeddedContext"],
3121 true
3122 );
3123 assert_eq!(result["authMethods"][0]["type"], "terminal");
3124 assert_eq!(
3125 result["authMethods"][0]["args"],
3126 json!(["auth", "set", "--provider", "deepseek"])
3127 );
3128 }
3129
3130 #[test]
3131 fn initialize_advertises_model_selection_capability() {
3132 let result = initialize_result(Some(1), &Config::default());
3133
3134 assert_eq!(result["agentCapabilities"]["modelSelection"], true);
3135 }
3136
3137 #[test]
3138 fn list_providers_returns_provider_set() {
3139 let server = AcpServer::new(
3140 Config::default(),
3141 "deepseek-chat".into(),
3142 PathBuf::from("/tmp"),
3143 );
3144 let result = server.list_providers();
3145 let providers = result["providers"].as_array().expect("providers array");
3146
3147 assert!(!providers.is_empty());
3148 assert!(
3149 providers
3150 .iter()
3151 .any(|provider| provider["id"] == "deepseek")
3152 );
3153 }
3154
3155 #[test]
3156 fn current_model_reflects_constructor_default() {
3157 let config = Config::default();
3158 let expected_provider = config.api_provider().as_str();
3159 let server = AcpServer::new(config, "deepseek-reasoner".into(), PathBuf::from("/tmp"));
3160 let result = server.current_model();
3161
3162 assert_eq!(result["provider"], expected_provider);
3163 assert_eq!(result["model"], "deepseek-reasoner");
3164 }
3165
3166 #[test]
3167 fn select_model_updates_active_selection() {
3168 let mut server = AcpServer::new(
3169 Config::default(),
3170 "deepseek-chat".into(),
3171 PathBuf::from("/tmp"),
3172 );
3173
3174 let result = server
3175 .select_model(json!({ "provider": "openai", "model": "gpt-4o" }))
3176 .expect("select model");
3177
3178 assert_eq!(result["provider"], "openai");
3179 assert_eq!(result["model"], "gpt-4o");
3180 assert_eq!(server.current_model()["provider"], "openai");
3181 assert_eq!(server.current_model()["model"], "gpt-4o");
3182 }
3183
3184 #[test]
3185 fn select_model_rejects_unknown_provider() {
3186 let mut server = AcpServer::new(
3187 Config::default(),
3188 "deepseek-chat".into(),
3189 PathBuf::from("/tmp"),
3190 );
3191 let before = server.current_model();
3192
3193 let err = server
3194 .select_model(json!({ "provider": "unknown-provider", "model": "gpt-4o" }))
3195 .expect_err("unknown provider rejected");
3196
3197 assert_eq!(err.code, -32602);
3198 assert_eq!(server.current_model(), before);
3199 }
3200
3201 #[test]
3202 fn select_model_rejects_missing_model() {
3203 let mut server = AcpServer::new(
3204 Config::default(),
3205 "deepseek-chat".into(),
3206 PathBuf::from("/tmp"),
3207 );
3208
3209 let err = server
3210 .select_model(json!({ "provider": "openai" }))
3211 .expect_err("missing model rejected");
3212
3213 assert_eq!(err.code, -32602);
3214 }
3215
3216 #[test]
3217 fn extract_prompt_text_accepts_text_and_resource_blocks() {
3218 let prompt = json!([
3219 { "type": "text", "text": "Review this file" },
3220 {
3221 "type": "resource",
3222 "resource": {
3223 "uri": "file:///tmp/app.rs",
3224 "mimeType": "text/rust",
3225 "text": "fn main() {}"
3226 }
3227 },
3228 { "type": "resource_link", "uri": "file:///tmp/lib.rs" }
3229 ]);
3230
3231 let text = extract_prompt_text(Some(&prompt)).expect("prompt text");
3232
3233 assert!(text.contains("Review this file"));
3234 assert!(text.contains("fn main() {}"));
3235 assert!(text.contains("@file:///tmp/lib.rs"));
3236 }
3237
3238 #[tokio::test]
3239 async fn session_update_is_protocol_clean_single_line_json() {
3240 let mut out = Vec::new();
3241
3242 write_session_update(&mut out, "sess_1", "hello\nworld".to_string())
3243 .await
3244 .expect("write update");
3245
3246 let line = String::from_utf8(out).expect("utf8");
3247 assert_eq!(line.lines().count(), 1);
3248 let value: Value = serde_json::from_str(line.trim()).expect("json");
3249 assert_eq!(value["method"], "session/update");
3250 assert_eq!(value["params"]["sessionId"], "sess_1");
3251 assert_eq!(value["params"]["update"]["content"]["text"], "hello\nworld");
3252 }
3253
3254 #[tokio::test]
3255 async fn jsonrpc_result_preserves_numeric_ids_for_avante_acp() {
3256 let mut out = Vec::new();
3257
3258 let params = json!({
3259 "protocolVersion": 1,
3260 "clientCapabilities": {}
3261 });
3262 let id = JsonRpcResponseIdPolicy::from_initialize_params(&params).response_id(json!(1));
3263 write_jsonrpc_result(&mut out, id, json!({"ok": true}))
3264 .await
3265 .expect("write result");
3266
3267 let line = String::from_utf8(out).expect("utf8");
3268 let value: Value = serde_json::from_str(line.trim()).expect("json");
3269 // Numeric ID must stay numeric — avante.nvim's Lua client uses
3270 // strict table keys (callbacks[1] ≠ callbacks["1"]).
3271 assert!(
3272 value["id"].is_number(),
3273 "numeric id must stay numeric, got {:?}",
3274 value["id"]
3275 );
3276 assert_eq!(value["result"], json!({"ok": true}));
3277 }
3278
3279 #[tokio::test]
3280 async fn jsonrpc_result_stringifies_numeric_ids_for_zed_acp() {
3281 let mut out = Vec::new();
3282
3283 let params = json!({
3284 "protocolVersion": 1,
3285 "clientCapabilities": {},
3286 "clientInfo": {
3287 "name": "zed",
3288 "version": "1.2.6"
3289 }
3290 });
3291 let id = JsonRpcResponseIdPolicy::from_initialize_params(&params).response_id(json!(1));
3292 write_jsonrpc_result(&mut out, id, json!({"ok": true}))
3293 .await
3294 .expect("write result");
3295
3296 let line = String::from_utf8(out).expect("utf8");
3297 let value: Value = serde_json::from_str(line.trim()).expect("json");
3298 assert_eq!(value["id"], "1");
3299 assert_eq!(value["result"], json!({"ok": true}));
3300 }
3301
3302 #[tokio::test]
3303 async fn jsonrpc_error_keeps_absent_id_null() {
3304 let mut out = Vec::new();
3305
3306 write_jsonrpc_error(&mut out, None, -32700, "invalid json")
3307 .await
3308 .expect("write error");
3309
3310 let line = String::from_utf8(out).expect("utf8");
3311 let value: Value = serde_json::from_str(line.trim()).expect("json");
3312 assert_eq!(value["id"], Value::Null);
3313 assert_eq!(value["error"]["code"], -32700);
3314 }
3315
3316 #[test]
3317 fn new_session_starts_with_empty_messages() {
3318 let mut server = AcpServer::new(
3319 Config::default(),
3320 "test-model".to_string(),
3321 PathBuf::from("/tmp"),
3322 );
3323 let result = server
3324 .new_session(json!({ "cwd": "/tmp" }))
3325 .expect("new session");
3326 let session_id = result["sessionId"].as_str().expect("session id");
3327 let session = server.sessions.get(session_id).expect("session exists");
3328 assert!(session.messages.is_empty());
3329 }
3330
3331 /// #6174: an ACP client has no id for a session it just created other than
3332 /// the one `session/new` returned, so that id must be loadable. It used to
3333 /// come back `codewhale-<uuid>` — a namespace `session/load` did not
3334 /// understand and the durable store never held — and replaying it, which is
3335 /// the normal client behaviour, failed with `-32602`.
3336 #[test]
3337 fn session_new_returns_an_id_that_session_load_resolves() {
3338 let mut server = AcpServer::new(
3339 Config::default(),
3340 "test-model".to_string(),
3341 PathBuf::from("/tmp"),
3342 );
3343 let created = server
3344 .new_session(json!({ "cwd": "/tmp" }))
3345 .expect("new session");
3346 let session_id = created["sessionId"]
3347 .as_str()
3348 .expect("session id")
3349 .to_string();
3350
3351 // The id is in the one namespace every method understands: the bare
3352 // uuid shape `session/list` advertises for durable sessions.
3353 assert!(
3354 !session_id.starts_with("codewhale-"),
3355 "session/new must not mint a prefixed id, got {session_id}"
3356 );
3357 uuid::Uuid::parse_str(&session_id)
3358 .unwrap_or_else(|e| panic!("session/new must mint a bare uuid, got {session_id}: {e}"));
3359
3360 // Replaying that exact id resolves, and resolves to the same session.
3361 let loaded = server
3362 .load_session(json!({ "sessionId": session_id }))
3363 .expect("session/load must resolve an id session/new returned");
3364 assert_eq!(loaded["sessionId"].as_str(), Some(session_id.as_str()));
3365 }
3366
3367 /// The memory hit must not paper over a genuinely unknown id: that still
3368 /// has to reach the durable store and fail there.
3369 #[test]
3370 fn session_load_still_rejects_an_id_no_one_minted() {
3371 let mut server = AcpServer::new(
3372 Config::default(),
3373 "test-model".to_string(),
3374 PathBuf::from("/tmp"),
3375 );
3376 let unknown = uuid::Uuid::new_v4().to_string();
3377 assert!(
3378 server
3379 .load_session(json!({ "sessionId": unknown }))
3380 .is_err(),
3381 "an id from no namespace must not resolve"
3382 );
3383 }
3384
3385 #[test]
3386 fn prompt_appends_user_and_assistant_messages_to_history() {
3387 let mut server = AcpServer::new(
3388 Config::default(),
3389 "test-model".to_string(),
3390 PathBuf::from("/tmp"),
3391 );
3392 let result = server
3393 .new_session(json!({ "cwd": "/tmp" }))
3394 .expect("new session");
3395 let session_id = result["sessionId"].as_str().unwrap().to_string();
3396
3397 // Simulate adding a user message (same logic as prompt() but without LLM call)
3398 {
3399 let session = server.sessions.get_mut(&session_id).unwrap();
3400 session.messages.push(Message {
3401 role: Role::User,
3402 content: vec![ContentBlock::Text {
3403 text: "1+1".to_string(),
3404 cache_control: None,
3405 }],
3406 });
3407 }
3408
3409 // Simulate assistant response
3410 {
3411 let session = server.sessions.get_mut(&session_id).unwrap();
3412 session.messages.push(Message {
3413 role: Role::Assistant,
3414 content: vec![ContentBlock::Text {
3415 text: "2".to_string(),
3416 cache_control: None,
3417 }],
3418 });
3419 }
3420
3421 // Second user message
3422 {
3423 let session = server.sessions.get_mut(&session_id).unwrap();
3424 session.messages.push(Message {
3425 role: Role::User,
3426 content: vec![ContentBlock::Text {
3427 text: "add one more".to_string(),
3428 cache_control: None,
3429 }],
3430 });
3431 }
3432
3433 // Verify full conversation history
3434 let session = server.sessions.get(&session_id).unwrap();
3435 assert_eq!(session.messages.len(), 3);
3436 assert_eq!(session.messages[0].role, "user");
3437 assert_eq!(session.messages[1].role, "assistant");
3438 assert_eq!(session.messages[2].role, "user");
3439
3440 // Verify text content
3441 assert_eq!(
3442 match &session.messages[0].content[0] {
3443 ContentBlock::Text { text, .. } => text.clone(),
3444 _ => String::new(),
3445 },
3446 "1+1"
3447 );
3448 assert_eq!(
3449 match &session.messages[1].content[0] {
3450 ContentBlock::Text { text, .. } => text.clone(),
3451 _ => String::new(),
3452 },
3453 "2"
3454 );
3455 assert_eq!(
3456 match &session.messages[2].content[0] {
3457 ContentBlock::Text { text, .. } => text.clone(),
3458 _ => String::new(),
3459 },
3460 "add one more"
3461 );
3462 }
3463
3464 fn lines_from(input: &'static str) -> Lines<BufReader<&'static [u8]>> {
3465 BufReader::new(input.as_bytes()).lines()
3466 }
3467
3468 fn text_delta(text: &str) -> StreamEvent {
3469 StreamEvent::ContentBlockDelta {
3470 index: 0,
3471 delta: Delta::TextDelta {
3472 text: text.to_string(),
3473 },
3474 }
3475 }
3476
3477 /// Simulate one streamed `tool_use` content block at `index`: a start
3478 /// event carrying the id/name, an `input_json_delta` with the full
3479 /// arguments JSON, and the closing stop event — matching the real
3480 /// provider's per-index streaming shape closely enough to exercise
3481 /// [`drive_prompt_stream`]'s accumulator.
3482 fn tool_use_events(index: u32, id: &str, name: &str, input_json: &str) -> Vec<StreamEvent> {
3483 vec![
3484 StreamEvent::ContentBlockStart {
3485 index,
3486 content_block: ContentBlockStart::ToolUse {
3487 id: id.to_string(),
3488 name: name.to_string(),
3489 input: json!({}),
3490 caller: None,
3491 thought_signature: None,
3492 },
3493 },
3494 StreamEvent::ContentBlockDelta {
3495 index,
3496 delta: Delta::InputJsonDelta {
3497 partial_json: input_json.to_string(),
3498 },
3499 },
3500 StreamEvent::ContentBlockStop { index },
3501 ]
3502 }
3503
3504 /// A stream that yields the given events immediately, then ends.
3505 fn ready_stream(events: Vec<StreamEvent>) -> StreamEventBox {
3506 Box::pin(futures_util::stream::iter(
3507 events.into_iter().map(Ok::<_, anyhow::Error>),
3508 ))
3509 }
3510
3511 fn error_stream(message: &'static str) -> StreamEventBox {
3512 Box::pin(futures_util::stream::iter(vec![Err(anyhow!(message))]))
3513 }
3514
3515 /// A stream that never yields, so a concurrent cancel always wins.
3516 fn pending_stream() -> StreamEventBox {
3517 Box::pin(futures_util::stream::pending::<Result<StreamEvent>>())
3518 }
3519
3520 /// A stream that yields `events` immediately, then emits `message_stop`
3521 /// after a short delay — long enough that an already-buffered reader line is
3522 /// processed first, making the ordering deterministic in tests.
3523 fn events_then_delayed_stop(events: Vec<StreamEvent>) -> StreamEventBox {
3524 let head = futures_util::stream::iter(events.into_iter().map(Ok::<_, anyhow::Error>));
3525 let tail = futures_util::stream::once(async {
3526 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3527 Ok(StreamEvent::MessageStop)
3528 });
3529 Box::pin(head.chain(tail))
3530 }
3531
3532 fn parse_lines(out: Vec<u8>) -> Vec<Value> {
3533 String::from_utf8(out)
3534 .expect("utf8")
3535 .lines()
3536 .filter(|line| !line.trim().is_empty())
3537 .map(|line| serde_json::from_str(line).expect("json"))
3538 .collect()
3539 }
3540
3541 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3542 enum PermissionClientScript {
3543 Allow,
3544 Reject,
3545 WrongIdThenReject,
3546 CancelThenLateAllow,
3547 AllowThenCancelRunning,
3548 }
3549
3550 async fn write_client_message(writer: &mut tokio::io::DuplexStream, message: Value) {
3551 writer
3552 .write_all(format!("{message}\n").as_bytes())
3553 .await
3554 .expect("write simulated ACP client message");
3555 }
3556
3557 async fn drive_permission_client(
3558 output: tokio::io::DuplexStream,
3559 mut input: tokio::io::DuplexStream,
3560 script: PermissionClientScript,
3561 must_not_exist_before_response: Option<PathBuf>,
3562 ) -> Vec<Value> {
3563 let mut output = BufReader::new(output).lines();
3564 let mut seen = Vec::new();
3565 let mut sent_running_cancel = false;
3566 while let Some(line) = output.next_line().await.expect("read agent output") {
3567 let message: Value = serde_json::from_str(&line).expect("agent output json");
3568 seen.push(message.clone());
3569
3570 if message.get("method").and_then(Value::as_str) == Some("session/request_permission") {
3571 if let Some(path) = must_not_exist_before_response.as_ref() {
3572 assert!(
3573 !path.exists(),
3574 "sensitive tool ran before the permission response: {}",
3575 path.display()
3576 );
3577 }
3578 let request_id = message["id"].clone();
3579 let selected = |option_id: &str| {
3580 json!({
3581 "jsonrpc": "2.0",
3582 "id": request_id.clone(),
3583 "result": {
3584 "outcome": {
3585 "outcome": "selected",
3586 "optionId": option_id
3587 }
3588 }
3589 })
3590 };
3591 match script {
3592 PermissionClientScript::Allow
3593 | PermissionClientScript::AllowThenCancelRunning => {
3594 write_client_message(&mut input, selected("allow-once")).await;
3595 }
3596 PermissionClientScript::Reject => {
3597 write_client_message(&mut input, selected("reject-once")).await;
3598 }
3599 PermissionClientScript::WrongIdThenReject => {
3600 write_client_message(
3601 &mut input,
3602 json!({
3603 "jsonrpc": "2.0",
3604 "id": "wrong-agent-request-id",
3605 "result": {
3606 "outcome": {
3607 "outcome": "selected",
3608 "optionId": "allow-once"
3609 }
3610 }
3611 }),
3612 )
3613 .await;
3614 write_client_message(&mut input, selected("reject-once")).await;
3615 }
3616 PermissionClientScript::CancelThenLateAllow => {
3617 write_client_message(
3618 &mut input,
3619 json!({
3620 "jsonrpc": "2.0",
3621 "method": "session/cancel",
3622 "params": { "sessionId": "sess_1" }
3623 }),
3624 )
3625 .await;
3626 write_client_message(
3627 &mut input,
3628 json!({
3629 "jsonrpc": "2.0",
3630 "id": request_id.clone(),
3631 "result": { "outcome": { "outcome": "cancelled" } }
3632 }),
3633 )
3634 .await;
3635 write_client_message(&mut input, selected("allow-once")).await;
3636 }
3637 }
3638 }
3639
3640 let update_status = message
3641 .pointer("/params/update/status")
3642 .and_then(Value::as_str);
3643 if script == PermissionClientScript::AllowThenCancelRunning
3644 && update_status == Some("in_progress")
3645 && !sent_running_cancel
3646 {
3647 sent_running_cancel = true;
3648 tokio::time::sleep(Duration::from_millis(100)).await;
3649 write_client_message(
3650 &mut input,
3651 json!({
3652 "jsonrpc": "2.0",
3653 "id": 7,
3654 "method": "session/cancel",
3655 "params": { "sessionId": "sess_1" }
3656 }),
3657 )
3658 .await;
3659 }
3660 if matches!(update_status, Some("completed" | "failed")) {
3661 break;
3662 }
3663 }
3664 seen
3665 }
3666
3667 async fn execute_one_with_permission_client(
3668 config: &Config,
3669 registry: &ToolRegistry,
3670 call: PendingToolCall,
3671 script: PermissionClientScript,
3672 response_id_policy: JsonRpcResponseIdPolicy,
3673 must_not_exist_before_response: Option<PathBuf>,
3674 ) -> (ToolBatchOutcome, Vec<Value>, Option<Value>) {
3675 let (client_input, agent_input) = tokio::io::duplex(64 * 1024);
3676 let (agent_output, client_output) = tokio::io::duplex(64 * 1024);
3677 let client = tokio::spawn(drive_permission_client(
3678 client_output,
3679 client_input,
3680 script,
3681 must_not_exist_before_response,
3682 ));
3683 let mut reader = BufReader::new(agent_input).lines();
3684 let mut writer = agent_output;
3685
3686 let outcome = execute_tool_calls_with_cancellation(
3687 AcpTurnContext {
3688 config,
3689 model: "test-model",
3690 session_id: "sess_1",
3691 tool_registry: registry,
3692 response_id_policy,
3693 },
3694 vec![call],
3695 &mut reader,
3696 &mut writer,
3697 )
3698 .await
3699 .expect("execute ACP tool batch");
3700 let late_response = if script == PermissionClientScript::CancelThenLateAllow {
3701 let line = tokio::time::timeout(Duration::from_secs(1), reader.next_line())
3702 .await
3703 .expect("late permission response arrived")
3704 .expect("read late permission response")
3705 .expect("late permission response line");
3706 Some(serde_json::from_str(&line).expect("late response json"))
3707 } else {
3708 None
3709 };
3710 drop(writer);
3711 let seen = client.await.expect("simulated ACP client joins");
3712 (outcome, seen, late_response)
3713 }
3714
3715 #[tokio::test]
3716 async fn drive_prompt_streams_each_delta_as_a_chunk_then_completes() {
3717 let stream = ready_stream(vec![
3718 text_delta("hello"),
3719 text_delta(" world"),
3720 StreamEvent::MessageStop,
3721 ]);
3722 let mut reader = lines_from("");
3723 let mut out = Vec::new();
3724
3725 let (outcome, tool_calls) = drive_prompt_stream(
3726 stream,
3727 "sess_1",
3728 JsonRpcResponseIdPolicy::Preserve,
3729 &mut reader,
3730 &mut out,
3731 )
3732 .await
3733 .expect("driver ok");
3734
3735 // Full text is accumulated for history...
3736 assert_eq!(outcome, PromptOutcome::Completed("hello world".to_string()));
3737 assert!(tool_calls.is_empty());
3738 // ...and each delta was emitted as its own session/update chunk.
3739 let updates = parse_lines(out);
3740 assert_eq!(updates.len(), 2);
3741 assert!(updates.iter().all(|u| u["method"] == "session/update"));
3742 assert_eq!(updates[0]["params"]["update"]["content"]["text"], "hello");
3743 assert_eq!(updates[1]["params"]["update"]["content"]["text"], " world");
3744 }
3745
3746 #[tokio::test]
3747 async fn drive_prompt_cancels_when_matching_cancel_arrives() {
3748 // A provider stream that never finishes within the test.
3749 let stream = pending_stream();
3750 let mut reader = lines_from(
3751 r#"{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess_1"}}"#,
3752 );
3753 let mut out = Vec::new();
3754
3755 let (outcome, tool_calls) = drive_prompt_stream(
3756 stream,
3757 "sess_1",
3758 JsonRpcResponseIdPolicy::Preserve,
3759 &mut reader,
3760 &mut out,
3761 )
3762 .await
3763 .expect("driver ok");
3764
3765 assert_eq!(outcome, PromptOutcome::Cancelled);
3766 assert!(tool_calls.is_empty());
3767 // Notification-form cancel (no id) is acknowledged by acting, not writing.
3768 assert!(out.is_empty());
3769 }
3770
3771 #[tokio::test]
3772 async fn drive_prompt_ignores_cancel_for_a_different_session() {
3773 // The unrelated cancel line is buffered and ready; the delayed stop makes
3774 // it process first, proving it does not abort the turn.
3775 let stream = events_then_delayed_stop(vec![text_delta("kept")]);
3776 let mut reader = lines_from(
3777 r#"{"jsonrpc":"2.0","id":7,"method":"session/cancel","params":{"sessionId":"other"}}"#,
3778 );
3779 let mut out = Vec::new();
3780
3781 let (outcome, _tool_calls) = drive_prompt_stream(
3782 stream,
3783 "sess_1",
3784 JsonRpcResponseIdPolicy::StringifyNumeric,
3785 &mut reader,
3786 &mut out,
3787 )
3788 .await
3789 .expect("driver ok");
3790
3791 assert_eq!(outcome, PromptOutcome::Completed("kept".to_string()));
3792 // The other-session cancel carried an id, so it was acknowledged with null.
3793 let lines = parse_lines(out);
3794 assert!(
3795 lines
3796 .iter()
3797 .any(|v| v["id"] == "7" && v["result"] == Value::Null),
3798 "expected a null ack for the other-session cancel, got {lines:?}"
3799 );
3800 }
3801
3802 #[tokio::test]
3803 async fn drive_prompt_rejects_a_concurrent_request_but_keeps_running() {
3804 let stream = events_then_delayed_stop(vec![text_delta("done")]);
3805 // A non-cancel request arrives mid-turn.
3806 let mut reader =
3807 lines_from(r#"{"jsonrpc":"2.0","id":9,"method":"session/new","params":{}}"#);
3808 let mut out = Vec::new();
3809
3810 let (outcome, _tool_calls) = drive_prompt_stream(
3811 stream,
3812 "sess_1",
3813 JsonRpcResponseIdPolicy::StringifyNumeric,
3814 &mut reader,
3815 &mut out,
3816 )
3817 .await
3818 .expect("driver ok");
3819
3820 assert_eq!(outcome, PromptOutcome::Completed("done".to_string()));
3821 let lines = parse_lines(out);
3822 assert!(
3823 lines
3824 .iter()
3825 .any(|v| v["id"] == "9" && v["error"]["code"] == -32603),
3826 "expected a prompt-in-progress error for the concurrent request, got {lines:?}"
3827 );
3828 }
3829
3830 #[tokio::test]
3831 async fn drive_prompt_assembles_a_single_streamed_tool_call() {
3832 let mut events = tool_use_events(0, "call_1", "read_file", r#"{"path":"src/lib.rs"}"#);
3833 events.push(StreamEvent::MessageStop);
3834 let stream = ready_stream(events);
3835 let mut reader = lines_from("");
3836 let mut out = Vec::new();
3837
3838 let (outcome, tool_calls) = drive_prompt_stream(
3839 stream,
3840 "sess_1",
3841 JsonRpcResponseIdPolicy::Preserve,
3842 &mut reader,
3843 &mut out,
3844 )
3845 .await
3846 .expect("driver ok");
3847
3848 assert_eq!(outcome, PromptOutcome::Completed(String::new()));
3849 assert_eq!(tool_calls.len(), 1);
3850 assert_eq!(tool_calls[0].id, "call_1");
3851 assert_eq!(tool_calls[0].name, "read_file");
3852 assert_eq!(tool_calls[0].input, json!({"path": "src/lib.rs"}));
3853 assert!(tool_calls[0].parse_error.is_none());
3854 }
3855
3856 #[tokio::test]
3857 async fn drive_prompt_assembles_multiple_parallel_tool_calls_in_order() {
3858 let mut events = tool_use_events(0, "call_1", "read_file", r#"{"path":"a.rs"}"#);
3859 events.extend(tool_use_events(
3860 1,
3861 "call_2",
3862 "read_file",
3863 r#"{"path":"b.rs"}"#,
3864 ));
3865 events.push(StreamEvent::MessageStop);
3866 let stream = ready_stream(events);
3867 let mut reader = lines_from("");
3868 let mut out = Vec::new();
3869
3870 let (_outcome, tool_calls) = drive_prompt_stream(
3871 stream,
3872 "sess_1",
3873 JsonRpcResponseIdPolicy::Preserve,
3874 &mut reader,
3875 &mut out,
3876 )
3877 .await
3878 .expect("driver ok");
3879
3880 assert_eq!(tool_calls.len(), 2);
3881 assert_eq!(tool_calls[0].id, "call_1");
3882 assert_eq!(tool_calls[1].id, "call_2");
3883 }
3884
3885 #[tokio::test]
3886 async fn drive_prompt_reports_malformed_tool_arguments_instead_of_dropping_the_call() {
3887 let mut events = tool_use_events(0, "call_1", "read_file", "{not json");
3888 events.push(StreamEvent::MessageStop);
3889 let stream = ready_stream(events);
3890 let mut reader = lines_from("");
3891 let mut out = Vec::new();
3892
3893 let (_outcome, tool_calls) = drive_prompt_stream(
3894 stream,
3895 "sess_1",
3896 JsonRpcResponseIdPolicy::Preserve,
3897 &mut reader,
3898 &mut out,
3899 )
3900 .await
3901 .expect("driver ok");
3902
3903 assert_eq!(tool_calls.len(), 1);
3904 assert!(tool_calls[0].parse_error.is_some());
3905 }
3906
3907 #[test]
3908 fn different_sessions_have_independent_history() {
3909 let mut server = AcpServer::new(
3910 Config::default(),
3911 "test-model".to_string(),
3912 PathBuf::from("/tmp"),
3913 );
3914 let result1 = server
3915 .new_session(json!({ "cwd": "/tmp" }))
3916 .expect("session 1");
3917 let result2 = server
3918 .new_session(json!({ "cwd": "/tmp" }))
3919 .expect("session 2");
3920 let sid1 = result1["sessionId"].as_str().unwrap().to_string();
3921 let sid2 = result2["sessionId"].as_str().unwrap().to_string();
3922
3923 // Add messages to session 1
3924 {
3925 let session = server.sessions.get_mut(&sid1).unwrap();
3926 session.messages.push(Message {
3927 role: Role::User,
3928 content: vec![ContentBlock::Text {
3929 text: "hello".to_string(),
3930 cache_control: None,
3931 }],
3932 });
3933 }
3934
3935 // Session 2 should remain empty
3936 let session2 = server.sessions.get(&sid2).unwrap();
3937 assert!(session2.messages.is_empty());
3938
3939 // Session 1 should have the message
3940 let session1 = server.sessions.get(&sid1).unwrap();
3941 assert_eq!(session1.messages.len(), 1);
3942 }
3943
3944 #[test]
3945 fn concurrent_sessions_each_get_their_own_tool_registry() {
3946 let mut server = AcpServer::new(
3947 Config {
3948 allow_shell: Some(true),
3949 ..Config::default()
3950 },
3951 "test-model".to_string(),
3952 PathBuf::from("/tmp"),
3953 );
3954 // Both config opt-in and the client terminal capability are present.
3955 server.client_supports_terminal = true;
3956 let s1 = server.new_session(json!({ "cwd": "/tmp" })).unwrap();
3957 let s2 = server.new_session(json!({ "cwd": "/tmp" })).unwrap();
3958 let id1 = s1["sessionId"].as_str().unwrap();
3959 let id2 = s2["sessionId"].as_str().unwrap();
3960
3961 let reg1 = server.session_tool_registry(id1).expect("registry 1");
3962 let reg2 = server.session_tool_registry(id2).expect("registry 2");
3963 assert!(!Arc::ptr_eq(&reg1, &reg2));
3964 // Both sessions expose the same reusable tool surface: the
3965 // canonical `File` action tool (read/list/search/write/edit),
3966 // `Git`, the `apply_patch` back-compat alias, and `Bash` (#4625
3967 // consolidated the old per-action tool names).
3968 assert!(reg1.contains("File"));
3969 assert!(reg1.contains("Git"));
3970 assert!(reg1.contains("apply_patch"));
3971 assert!(reg1.contains("bash"));
3972 assert!(reg1.contains("Bash"));
3973 assert!(
3974 reg1.names()
3975 .into_iter()
3976 .all(|name| !name.starts_with("terminal/")),
3977 "ACP must not expose stateful terminal tools"
3978 );
3979 assert!(reg1.context().runtime.hook_executor.is_some());
3980 }
3981
3982 #[test]
3983 fn shell_tool_omitted_when_client_declares_no_terminal_support() {
3984 let workspace = std::env::temp_dir();
3985 let config = Config {
3986 allow_shell: Some(true),
3987 ..Config::default()
3988 };
3989 let registry = build_acp_tool_registry(&config, &workspace, false);
3990 assert!(!registry.contains("Bash"));
3991 assert!(registry.contains("File"));
3992 }
3993
3994 #[test]
3995 fn shell_tool_omitted_without_headless_config_opt_in() {
3996 let workspace = std::env::temp_dir();
3997 let registry = build_acp_tool_registry(&Config::default(), &workspace, true);
3998 assert!(!registry.contains("Bash"));
3999 assert_eq!(registry.context().shell_policy, ShellPolicy::None);
4000 assert!(!registry.context().auto_approve);
4001 }
4002
4003 #[test]
4004 fn acp_shell_uses_configured_external_sandbox_or_fails_closed() {
4005 let workspace = std::env::temp_dir();
4006 let configured = Config {
4007 allow_shell: Some(true),
4008 sandbox_backend: Some("opensandbox".to_string()),
4009 sandbox_url: Some("http://127.0.0.1:8080".to_string()),
4010 ..Config::default()
4011 };
4012 let registry = build_acp_tool_registry(&configured, &workspace, true);
4013 assert!(registry.contains("bash"));
4014 assert!(registry.context().sandbox_backend.is_some());
4015
4016 let unsupported = Config {
4017 allow_shell: Some(true),
4018 sandbox_backend: Some("unsupported-backend".to_string()),
4019 ..Config::default()
4020 };
4021 let registry = build_acp_tool_registry(&unsupported, &workspace, true);
4022 assert!(!registry.contains("bash"));
4023 assert!(!registry.contains("Bash"));
4024 assert!(registry.context().sandbox_backend.is_none());
4025 }
4026
4027 #[test]
4028 fn acp_tool_override_removes_every_builtin_compatibility_alias() {
4029 let mut overrides = std::collections::HashMap::new();
4030 overrides.insert("Bash".to_string(), crate::config::ToolOverride::Disabled);
4031 let config = Config {
4032 allow_shell: Some(true),
4033 tools: Some(crate::config::ToolsConfig {
4034 overrides: Some(overrides),
4035 ..crate::config::ToolsConfig::default()
4036 }),
4037 ..Config::default()
4038 };
4039 let registry = build_acp_tool_registry(&config, &std::env::temp_dir(), true);
4040 assert!(!registry.contains("bash"));
4041 assert!(!registry.contains("Bash"));
4042 assert!(
4043 registry
4044 .names()
4045 .into_iter()
4046 .all(|name| !name.starts_with("terminal/"))
4047 );
4048 }
4049
4050 #[test]
4051 fn acp_prompt_uses_the_stable_headless_composer() {
4052 let workspace = tempfile::tempdir().expect("workspace");
4053 std::fs::write(
4054 workspace.path().join("AGENTS.md"),
4055 "# ACP project law\n\nKeep the acp-project-marker visible.",
4056 )
4057 .expect("write project instructions");
4058 let extra = workspace.path().join("maintainer-instructions.md");
4059 std::fs::write(&extra, "Keep the acp-config-marker visible.")
4060 .expect("write configured instructions");
4061 let config = Config {
4062 instructions: Some(vec![extra.to_string_lossy().into_owned()]),
4063 ..Config::default()
4064 };
4065
4066 let prompt = build_acp_system_prompt(
4067 &config,
4068 workspace.path(),
4069 ApiProvider::Deepseek,
4070 "deepseek-v4-pro",
4071 None,
4072 );
4073 let text = crate::prompts::system_prompt_flat_text(&prompt);
4074
4075 assert!(text.contains(crate::prompts::text::BASE_PROMPT.trim()));
4076 assert!(text.contains("acp-project-marker"));
4077 assert!(text.contains("acp-config-marker"));
4078 assert!(!text.contains("You are a coding assistant inside an ACP-compatible editor."));
4079 }
4080
4081 #[test]
4082 fn acp_system_prompt_is_byte_stable_after_round_one_agents_write() {
4083 let workspace = tempfile::tempdir().expect("workspace");
4084 let agents = workspace.path().join("AGENTS.md");
4085 std::fs::write(&agents, "round-one-authority").expect("write initial AGENTS");
4086 let config = Config::default();
4087 let slot = std::sync::Mutex::new(None);
4088
4089 let round_one = frozen_acp_system_prompt(
4090 &slot,
4091 &config,
4092 workspace.path(),
4093 ApiProvider::Deepseek,
4094 "deepseek-v4-pro",
4095 None,
4096 );
4097 // Simulate a model tool changing project instructions during round 1.
4098 std::fs::write(&agents, "round-two-self-authored-authority")
4099 .expect("mutate AGENTS between rounds");
4100 let round_two = frozen_acp_system_prompt(
4101 &slot,
4102 &config,
4103 workspace.path(),
4104 ApiProvider::Deepseek,
4105 "deepseek-v4-pro",
4106 None,
4107 );
4108
4109 assert_eq!(
4110 serde_json::to_vec(&round_one).unwrap(),
4111 serde_json::to_vec(&round_two).unwrap(),
4112 "later rounds must receive the byte-identical first-round system prompt"
4113 );
4114 let freshly_composed = build_acp_system_prompt(
4115 &config,
4116 workspace.path(),
4117 ApiProvider::Deepseek,
4118 "deepseek-v4-pro",
4119 None,
4120 );
4121 assert!(
4122 crate::prompts::system_prompt_flat_text(&freshly_composed)
4123 .contains("round-two-self-authored-authority"),
4124 "fixture must prove the mutable source really changed"
4125 );
4126 assert!(
4127 !crate::prompts::system_prompt_flat_text(&round_two)
4128 .contains("round-two-self-authored-authority")
4129 );
4130 }
4131
4132 fn workspace_registry() -> (tempfile::TempDir, ToolRegistry) {
4133 let dir = tempfile::tempdir().expect("tempdir");
4134 let config = Config {
4135 allow_shell: Some(true),
4136 ..Config::default()
4137 };
4138 let registry = build_acp_tool_registry(&config, dir.path(), true);
4139 (dir, registry)
4140 }
4141
4142 fn pending_call(name: &str, input: Value) -> PendingToolCall {
4143 PendingToolCall {
4144 id: "call_1".to_string(),
4145 name: name.to_string(),
4146 input,
4147 parse_error: None,
4148 }
4149 }
4150
4151 fn config_with_policy_rule(rule: codewhale_execpolicy::ToolAskRule) -> Config {
4152 Config {
4153 exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![
4154 codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]),
4155 ]),
4156 ..Config::default()
4157 }
4158 }
4159
4160 fn tool_call_hook_command(payload: &Value) -> String {
4161 let payload = payload.to_string();
4162 if cfg!(windows) {
4163 format!("echo {payload}")
4164 } else {
4165 format!("printf '%s\\n' '{payload}'")
4166 }
4167 }
4168
4169 fn config_with_tool_call_hook(mut config: Config, payload: Value, strict: bool) -> Config {
4170 let mut hook = crate::hooks::Hook::new(
4171 crate::hooks::HookEvent::ToolCallBefore,
4172 &tool_call_hook_command(&payload),
4173 );
4174 hook.continue_on_error = !strict;
4175 config.hooks = Some(crate::hooks::HooksConfig {
4176 enabled: true,
4177 hooks: vec![hook],
4178 ..crate::hooks::HooksConfig::default()
4179 });
4180 config
4181 }
4182
4183 #[tokio::test]
4184 async fn acp_strict_tool_call_before_deny_blocks_before_execution() {
4185 let dir = tempfile::tempdir().expect("tempdir");
4186 let config = config_with_tool_call_hook(
4187 Config::default(),
4188 json!({"decision": "deny", "reason": "release gate"}),
4189 true,
4190 );
4191 let registry = build_acp_tool_registry(&config, dir.path(), false);
4192 let error = prepare_acp_tool_with_hooks(
4193 &config,
4194 "test-model",
4195 &registry,
4196 &pending_call("File", json!({"action": "read", "path": "safe.txt"})),
4197 )
4198 .await
4199 .expect_err("strict hook must deny");
4200
4201 assert!(error.to_string().contains("release gate"));
4202 }
4203
4204 #[tokio::test]
4205 async fn acp_hook_rewrite_is_reprepared_and_policy_is_re_evaluated() {
4206 let dir = tempfile::tempdir().expect("tempdir");
4207 let deny_rewritten_write = codewhale_execpolicy::ToolAskRule {
4208 action: codewhale_execpolicy::PermissionAction::Deny,
4209 ..codewhale_execpolicy::ToolAskRule::file_path("write_file", "rewritten.txt")
4210 };
4211 let config = config_with_tool_call_hook(
4212 config_with_policy_rule(deny_rewritten_write),
4213 json!({
4214 "updatedInput": {
4215 "action": "write",
4216 "path": "rewritten.txt",
4217 "content": "rewritten by hook"
4218 }
4219 }),
4220 true,
4221 );
4222 let registry = build_acp_tool_registry(&config, dir.path(), false);
4223 let raw = pending_call("File", json!({"action": "read", "path": "safe.txt"}));
4224 let (_, raw_admission) = prepare_acp_tool_admission(&config, &registry, &raw).unwrap();
4225 assert_eq!(raw_admission, AcpToolAdmission::Auto);
4226
4227 let prepared = prepare_acp_tool_with_hooks(&config, "test-model", &registry, &raw)
4228 .await
4229 .expect("hook rewrite prepares");
4230 assert_eq!(
4231 prepared.call.input.get("action").and_then(Value::as_str),
4232 Some("write")
4233 );
4234 assert!(matches!(prepared.admission, AcpToolAdmission::Block(_)));
4235 assert!(!dir.path().join("rewritten.txt").exists());
4236 }
4237
4238 #[test]
4239 fn acp_admission_is_input_specific_and_has_no_workspace_write_carve_out() {
4240 let (dir, registry) = workspace_registry();
4241 let config = Config::default();
4242 let read = pending_call("File", json!({"action": "read", "path": "src/lib.rs"}));
4243 let write = pending_call(
4244 "File",
4245 json!({"action": "write", "path": "src/lib.rs", "content": "new"}),
4246 );
4247
4248 let (_, read_admission) = prepare_acp_tool_admission(&config, &registry, &read).unwrap();
4249 let (_, write_admission) = prepare_acp_tool_admission(&config, &registry, &write).unwrap();
4250
4251 assert_eq!(read_admission, AcpToolAdmission::Auto);
4252 assert!(matches!(
4253 write_admission,
4254 AcpToolAdmission::RequestPermission(_)
4255 ));
4256 assert_eq!(registry.context().workspace, dir.path());
4257 }
4258
4259 #[test]
4260 fn acp_admission_folds_typed_rules_then_headless_safety_floor() {
4261 let (dir, registry) = workspace_registry();
4262 let workspace = dir.path().to_string_lossy().into_owned();
4263 let input = json!({"action": "write", "path": "allowed.txt", "content": "new"});
4264 let call = pending_call("File", input.clone());
4265
4266 let allow = codewhale_execpolicy::ToolAskRule::file_path("write_file", "allowed.txt")
4267 .into_exact_workspace_allow(workspace.clone());
4268 let (_, admission) =
4269 prepare_acp_tool_admission(&config_with_policy_rule(allow), &registry, &call).unwrap();
4270 assert_eq!(admission, AcpToolAdmission::Auto);
4271
4272 let ask = codewhale_execpolicy::ToolAskRule::file_path("write_file", "allowed.txt");
4273 let (_, admission) =
4274 prepare_acp_tool_admission(&config_with_policy_rule(ask), &registry, &call).unwrap();
4275 assert!(matches!(
4276 admission,
4277 AcpToolAdmission::RequestPermission(reason) if reason.contains("requires approval")
4278 ));
4279
4280 let deny = codewhale_execpolicy::ToolAskRule {
4281 action: codewhale_execpolicy::PermissionAction::Deny,
4282 ..codewhale_execpolicy::ToolAskRule::file_path("write_file", "allowed.txt")
4283 };
4284 let (_, admission) =
4285 prepare_acp_tool_admission(&config_with_policy_rule(deny), &registry, &call).unwrap();
4286 assert!(matches!(admission, AcpToolAdmission::Block(_)));
4287
4288 let command = "rm -rf ~/";
4289 let shell_allow = codewhale_execpolicy::ToolAskRule::exec_shell(command)
4290 .into_exact_workspace_allow(workspace);
4291 let shell_call = pending_call("Bash", json!({"command": command}));
4292 let (_, admission) = prepare_acp_tool_admission(
4293 &config_with_policy_rule(shell_allow),
4294 &registry,
4295 &shell_call,
4296 )
4297 .unwrap();
4298 assert!(matches!(
4299 admission,
4300 AcpToolAdmission::RequestPermission(reason)
4301 if reason.contains("Built-in safety gate")
4302 ));
4303 }
4304
4305 #[test]
4306 fn acp_admission_blocks_detached_and_stateful_bash_inputs() {
4307 let (_dir, registry) = workspace_registry();
4308 for input in [
4309 json!({"command": "sleep 30", "background": true}),
4310 json!({"command": "sleep 30", "tty": true}),
4311 json!({"command": "echo hi", "interactive": true}),
4312 json!({"command": "serve", "background": true, "persist": true}),
4313 json!({"command": "sleep 30 &"}),
4314 json!({"command": "nohup sleep 30"}),
4315 json!({"action": "wait", "task_id": "shell-1"}),
4316 json!({"action": "cancel", "task_id": "shell-1"}),
4317 ] {
4318 let call = pending_call("Bash", input);
4319 let (_, admission) =
4320 prepare_acp_tool_admission(&Config::default(), &registry, &call).unwrap();
4321 assert!(matches!(
4322 admission,
4323 AcpToolAdmission::Block(reason)
4324 if reason.contains("foreground Bash runs only")
4325 ));
4326 }
4327 assert!(!acp_shell_command_requests_detach("echo '&' && echo done"));
4328 }
4329
4330 #[test]
4331 fn acp_admission_auto_review_and_repo_law_override_typed_allow() {
4332 let (dir, registry) = workspace_registry();
4333 let workspace = dir.path().to_string_lossy().into_owned();
4334 let command = "cargo test";
4335 let shell_allow = codewhale_execpolicy::ToolAskRule::exec_shell(command)
4336 .into_exact_workspace_allow(workspace.clone());
4337 let mut auto_review_block = config_with_policy_rule(shell_allow);
4338 auto_review_block.auto_review = Some(crate::config::AutoReviewConfig {
4339 block: vec![crate::config::AutoReviewRuleConfig {
4340 id: Some("acp-shell-block".to_string()),
4341 action_kind: Some("shell".to_string()),
4342 reason: Some("ACP shell is disabled by policy".to_string()),
4343 ..Default::default()
4344 }],
4345 ..Default::default()
4346 });
4347 let (_, admission) = prepare_acp_tool_admission(
4348 &auto_review_block,
4349 &registry,
4350 &pending_call("Bash", json!({"command": command})),
4351 )
4352 .unwrap();
4353 assert!(matches!(
4354 admission,
4355 AcpToolAdmission::Block(reason) if reason.contains("ACP shell is disabled by policy")
4356 ));
4357
4358 let law_dir = dir.path().join(".codewhale");
4359 std::fs::create_dir_all(&law_dir).unwrap();
4360 std::fs::write(
4361 law_dir.join("constitution.json"),
4362 r#"{
4363 "protected_invariants": [
4364 { "text": "Never rewrite the wire", "paths": ["wire.rs"], "action": "block" },
4365 { "text": "Review release notes", "paths": ["CHANGELOG.md"] }
4366 ]
4367 }"#,
4368 )
4369 .unwrap();
4370
4371 for (path, expected_block) in [("wire.rs", true), ("CHANGELOG.md", false)] {
4372 let allow = codewhale_execpolicy::ToolAskRule::file_path("write_file", path)
4373 .into_exact_workspace_allow(workspace.clone());
4374 let config = config_with_policy_rule(allow);
4375 let call = pending_call(
4376 "File",
4377 json!({"action": "write", "path": path, "content": "new"}),
4378 );
4379 let (_, admission) = prepare_acp_tool_admission(&config, &registry, &call).unwrap();
4380 if expected_block {
4381 assert!(matches!(
4382 admission,
4383 AcpToolAdmission::Block(reason) if reason.contains("Never rewrite the wire")
4384 ));
4385 } else {
4386 assert!(matches!(
4387 admission,
4388 AcpToolAdmission::RequestPermission(reason)
4389 if reason.contains("Review release notes")
4390 ));
4391 }
4392 }
4393 }
4394
4395 #[tokio::test]
4396 async fn acp_read_runs_without_permission_but_reports_pending_before_in_progress() {
4397 let (dir, registry) = workspace_registry();
4398 std::fs::write(dir.path().join("read.txt"), "safe").unwrap();
4399 let mut reader = lines_from("");
4400 let mut out = Vec::new();
4401
4402 let outcome = execute_tool_calls_with_cancellation(
4403 AcpTurnContext {
4404 config: &Config::default(),
4405 model: "test-model",
4406 session_id: "sess_1",
4407 tool_registry: &registry,
4408 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
4409 },
4410 vec![pending_call(
4411 "File",
4412 json!({"action": "read", "path": "read.txt"}),
4413 )],
4414 &mut reader,
4415 &mut out,
4416 )
4417 .await
4418 .unwrap();
4419
4420 assert!(matches!(outcome, ToolBatchOutcome::Completed(_)));
4421 let messages = parse_lines(out);
4422 assert!(!messages.iter().any(|message| {
4423 message.get("method").and_then(Value::as_str) == Some("session/request_permission")
4424 }));
4425 let statuses = messages
4426 .iter()
4427 .filter_map(|message| {
4428 message
4429 .pointer("/params/update/status")
4430 .and_then(Value::as_str)
4431 })
4432 .collect::<Vec<_>>();
4433 assert_eq!(statuses, vec!["pending", "in_progress", "completed"]);
4434 }
4435
4436 #[tokio::test]
4437 async fn acp_permission_allow_executes_write_once_after_response() {
4438 let (dir, registry) = workspace_registry();
4439 let target = dir.path().join("allowed.txt");
4440 let (outcome, messages, late) = execute_one_with_permission_client(
4441 &Config::default(),
4442 &registry,
4443 pending_call(
4444 "File",
4445 json!({"action": "write", "path": "allowed.txt", "content": "written once"}),
4446 ),
4447 PermissionClientScript::Allow,
4448 JsonRpcResponseIdPolicy::Preserve,
4449 Some(target.clone()),
4450 )
4451 .await;
4452
4453 assert!(late.is_none());
4454 assert!(matches!(outcome, ToolBatchOutcome::Completed(_)));
4455 assert_eq!(std::fs::read_to_string(target).unwrap(), "written once");
4456 let request = messages
4457 .iter()
4458 .find(|message| message["method"] == "session/request_permission")
4459 .expect("permission request");
4460 assert_eq!(request["params"]["toolCall"]["status"], "pending");
4461 assert_eq!(request["params"]["options"][0]["kind"], "allow_once");
4462 assert_eq!(request["params"]["options"][1]["kind"], "reject_once");
4463 let statuses = messages
4464 .iter()
4465 .filter_map(|message| {
4466 message
4467 .pointer("/params/update/status")
4468 .and_then(Value::as_str)
4469 })
4470 .collect::<Vec<_>>();
4471 assert_eq!(statuses, vec!["pending", "in_progress", "completed"]);
4472 }
4473
4474 #[tokio::test]
4475 async fn acp_permission_reject_and_wrong_id_fail_closed_without_write() {
4476 for script in [
4477 PermissionClientScript::Reject,
4478 PermissionClientScript::WrongIdThenReject,
4479 ] {
4480 let (dir, registry) = workspace_registry();
4481 let target = dir.path().join("denied.txt");
4482 let (outcome, messages, _) = execute_one_with_permission_client(
4483 &Config::default(),
4484 &registry,
4485 pending_call(
4486 "File",
4487 json!({"action": "write", "path": "denied.txt", "content": "forbidden"}),
4488 ),
4489 script,
4490 JsonRpcResponseIdPolicy::Preserve,
4491 Some(target.clone()),
4492 )
4493 .await;
4494
4495 assert!(!target.exists(), "{script:?} must not authorize the write");
4496 let ToolBatchOutcome::Completed(results) = outcome else {
4497 panic!("rejection should complete with a failed tool result");
4498 };
4499 assert_eq!(results.len(), 1);
4500 let ContentBlock::ToolResult { is_error, .. } = &results[0].content[0] else {
4501 panic!("expected tool result");
4502 };
4503 assert_eq!(*is_error, Some(true));
4504 assert!(!messages.iter().any(|message| {
4505 message
4506 .pointer("/params/update/status")
4507 .and_then(Value::as_str)
4508 == Some("in_progress")
4509 }));
4510 }
4511 }
4512
4513 #[tokio::test]
4514 async fn acp_permission_cancel_ignores_late_allow_and_never_runs_tool() {
4515 let (dir, registry) = workspace_registry();
4516 let target = dir.path().join("cancelled.txt");
4517 let (outcome, messages, late) = execute_one_with_permission_client(
4518 &Config::default(),
4519 &registry,
4520 pending_call(
4521 "File",
4522 json!({"action": "write", "path": "cancelled.txt", "content": "forbidden"}),
4523 ),
4524 PermissionClientScript::CancelThenLateAllow,
4525 JsonRpcResponseIdPolicy::Preserve,
4526 Some(target.clone()),
4527 )
4528 .await;
4529
4530 assert!(!target.exists());
4531 assert!(matches!(outcome, ToolBatchOutcome::Cancelled(_)));
4532 assert!(messages.iter().any(|message| {
4533 message
4534 .pointer("/params/update/status")
4535 .and_then(Value::as_str)
4536 == Some("failed")
4537 }));
4538 let late = late.expect("late allow remains queued for the outer dispatcher");
4539 assert!(is_jsonrpc_response(&late));
4540 assert_eq!(
4541 late.pointer("/result/outcome/optionId")
4542 .and_then(Value::as_str),
4543 Some("allow-once")
4544 );
4545 }
4546
4547 #[tokio::test]
4548 async fn tool_registry_read_file_returns_real_contents() {
4549 let (dir, registry) = workspace_registry();
4550 std::fs::write(dir.path().join("hello.txt"), "hi there").unwrap();
4551
4552 let result = registry
4553 .execute_full("File", json!({"action": "read", "path": "hello.txt"}))
4554 .await
4555 .expect("read_file succeeds");
4556
4557 assert!(result.success);
4558 assert!(result.content.contains("hi there"));
4559 }
4560
4561 #[tokio::test]
4562 async fn tool_registry_write_file_creates_a_real_file() {
4563 let (dir, registry) = workspace_registry();
4564
4565 let result = registry
4566 .execute_full(
4567 "File",
4568 json!({"action": "write", "path": "created.txt", "content": "new content"}),
4569 )
4570 .await
4571 .expect("write_file succeeds");
4572
4573 assert!(result.success);
4574 let on_disk = std::fs::read_to_string(dir.path().join("created.txt")).unwrap();
4575 assert_eq!(on_disk, "new content");
4576 }
4577
4578 #[tokio::test]
4579 async fn tool_registry_list_dir_reports_real_directory_contents() {
4580 let (dir, registry) = workspace_registry();
4581 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
4582 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
4583
4584 let result = registry
4585 .execute_full("File", json!({"action": "list", "path": "."}))
4586 .await
4587 .expect("list_dir succeeds");
4588
4589 assert!(result.success);
4590 assert!(result.content.contains("a.txt"));
4591 assert!(result.content.contains("b.txt"));
4592 }
4593
4594 #[tokio::test]
4595 async fn tool_registry_bash_runs_a_real_command() {
4596 let (_dir, registry) = workspace_registry();
4597
4598 let result = registry
4599 .execute_full("Bash", json!({"command": "echo acp-terminal-check"}))
4600 .await
4601 .expect("Bash succeeds");
4602
4603 assert!(result.content.contains("acp-terminal-check"));
4604 }
4605
4606 #[tokio::test]
4607 async fn tool_registry_read_file_reports_failure_for_missing_path() {
4608 let (_dir, registry) = workspace_registry();
4609
4610 let err = registry
4611 .execute_full(
4612 "File",
4613 json!({"action": "read", "path": "does-not-exist.txt"}),
4614 )
4615 .await
4616 .expect_err("missing file is a tool error");
4617
4618 assert!(!err.to_string().is_empty());
4619 }
4620
4621 /// Feeds [`run_agentic_prompt_turn`] a fixed sequence of canned per-round
4622 /// streams (no real provider), so the multi-round tool loop — including
4623 /// nested tool calls across several rounds — is exercised end-to-end
4624 /// against the real file-tool registry. A plain struct + inherent async
4625 /// method (rather than a boxed closure) lets each test's `|msgs| scripted.next()`
4626 /// closure return the async method's own anonymous future type directly,
4627 /// so `Fut` is inferred without needing a `dyn Future` trait object.
4628 struct ScriptedStreams(RefCell<VecDeque<StreamEventBox>>);
4629
4630 impl ScriptedStreams {
4631 fn new(streams: Vec<StreamEventBox>) -> Self {
4632 Self(RefCell::new(VecDeque::from(streams)))
4633 }
4634
4635 async fn next(&self) -> Result<StreamEventBox> {
4636 Ok(self
4637 .0
4638 .borrow_mut()
4639 .pop_front()
4640 .expect("test provided enough scripted rounds"))
4641 }
4642 }
4643
4644 #[tokio::test]
4645 async fn agentic_turn_executes_a_tool_call_then_streams_the_final_answer() {
4646 let (dir, registry) = workspace_registry();
4647 std::fs::write(dir.path().join("VERSION"), "9.9.9").unwrap();
4648
4649 let round1 = ready_stream({
4650 let mut events =
4651 tool_use_events(0, "call_1", "File", r#"{"action":"read","path":"VERSION"}"#);
4652 events.push(StreamEvent::MessageStop);
4653 events
4654 });
4655 let round2 = ready_stream(vec![
4656 text_delta("The version is 9.9.9"),
4657 StreamEvent::MessageStop,
4658 ]);
4659
4660 let scripted = ScriptedStreams::new(vec![round1, round2]);
4661 let mut reader = lines_from("");
4662 let mut out = Vec::new();
4663
4664 let (outcome, messages) = run_agentic_prompt_turn(
4665 AcpTurnContext {
4666 config: &Config::default(),
4667 model: "test-model",
4668 session_id: "sess_1",
4669 tool_registry: &registry,
4670 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
4671 },
4672 vec![Message {
4673 role: Role::User,
4674 content: vec![ContentBlock::Text {
4675 text: "What version is this?".to_string(),
4676 cache_control: None,
4677 }],
4678 }],
4679 &mut reader,
4680 &mut out,
4681 |_msgs| scripted.next(),
4682 )
4683 .await
4684 .expect("turn completes");
4685
4686 assert_eq!(
4687 outcome,
4688 PromptOutcome::Completed("The version is 9.9.9".to_string())
4689 );
4690 // user -> assistant(tool_use) -> user(tool_result) -> assistant(text)
4691 assert_eq!(messages.len(), 4);
4692 assert!(matches!(
4693 messages[1].content[0],
4694 ContentBlock::ToolUse { .. }
4695 ));
4696 let ContentBlock::ToolResult {
4697 content, is_error, ..
4698 } = &messages[2].content[0]
4699 else {
4700 panic!("expected a tool_result message");
4701 };
4702 assert!(content.contains("9.9.9"));
4703 assert_eq!(*is_error, Some(false));
4704
4705 // The client saw a tool_call start, a completed update, and the
4706 // streamed final-answer chunk.
4707 let lines = parse_lines(out);
4708 assert!(
4709 lines
4710 .iter()
4711 .any(|v| v["params"]["update"]["sessionUpdate"] == "tool_call")
4712 );
4713 assert!(lines.iter().any(
4714 |v| v["params"]["update"]["sessionUpdate"] == "tool_call_update"
4715 && v["params"]["update"]["status"] == "completed"
4716 ));
4717 assert!(lines.iter().any(|v| v["params"]["update"]["sessionUpdate"]
4718 == "agent_message_chunk"
4719 && v["params"]["update"]["content"]["text"] == "The version is 9.9.9"));
4720 }
4721
4722 #[tokio::test]
4723 async fn agentic_turn_preserves_tool_receipts_when_later_provider_round_fails() {
4724 let (dir, registry) = workspace_registry();
4725 std::fs::write(dir.path().join("receipt.txt"), "observed").unwrap();
4726 let round1 = ready_stream({
4727 let mut events = tool_use_events(
4728 0,
4729 "call_receipt",
4730 "File",
4731 r#"{"action":"read","path":"receipt.txt"}"#,
4732 );
4733 events.push(StreamEvent::MessageStop);
4734 events
4735 });
4736 let scripted = ScriptedStreams::new(vec![round1, error_stream("provider unavailable")]);
4737 let mut reader = lines_from("");
4738 let mut out = Vec::new();
4739
4740 let error = run_agentic_prompt_turn(
4741 AcpTurnContext {
4742 config: &Config::default(),
4743 model: "test-model",
4744 session_id: "sess_1",
4745 tool_registry: &registry,
4746 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
4747 },
4748 vec![Message {
4749 role: Role::User,
4750 content: vec![ContentBlock::Text {
4751 text: "Read receipt.txt".to_string(),
4752 cache_control: None,
4753 }],
4754 }],
4755 &mut reader,
4756 &mut out,
4757 |_msgs| scripted.next(),
4758 )
4759 .await
4760 .expect_err("second provider round fails");
4761
4762 assert!(error.source.to_string().contains("provider unavailable"));
4763 let messages = error
4764 .partial_messages
4765 .expect("completed tool receipt must be returned for commit");
4766 assert_eq!(messages.len(), 3);
4767 assert!(matches!(
4768 &messages[2].content[0],
4769 ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call_receipt"
4770 ));
4771 }
4772
4773 #[tokio::test]
4774 async fn agentic_turn_chains_nested_tool_calls_across_rounds() {
4775 let (dir, registry) = workspace_registry();
4776 std::fs::write(dir.path().join("a.txt"), "contents-of-a").unwrap();
4777 std::fs::write(dir.path().join("b.txt"), "contents-of-b").unwrap();
4778
4779 let round1 = ready_stream({
4780 let mut events =
4781 tool_use_events(0, "call_1", "File", r#"{"action":"read","path":"a.txt"}"#);
4782 events.push(StreamEvent::MessageStop);
4783 events
4784 });
4785 // After seeing a.txt's contents, the model asks for b.txt too.
4786 let round2 = ready_stream({
4787 let mut events =
4788 tool_use_events(0, "call_2", "File", r#"{"action":"read","path":"b.txt"}"#);
4789 events.push(StreamEvent::MessageStop);
4790 events
4791 });
4792 let round3 = ready_stream(vec![
4793 text_delta("Both files read"),
4794 StreamEvent::MessageStop,
4795 ]);
4796
4797 let scripted = ScriptedStreams::new(vec![round1, round2, round3]);
4798 let mut reader = lines_from("");
4799 let mut out = Vec::new();
4800
4801 let (outcome, messages) = run_agentic_prompt_turn(
4802 AcpTurnContext {
4803 config: &Config::default(),
4804 model: "test-model",
4805 session_id: "sess_1",
4806 tool_registry: &registry,
4807 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
4808 },
4809 vec![Message {
4810 role: Role::User,
4811 content: vec![ContentBlock::Text {
4812 text: "Read both files".to_string(),
4813 cache_control: None,
4814 }],
4815 }],
4816 &mut reader,
4817 &mut out,
4818 |_msgs| scripted.next(),
4819 )
4820 .await
4821 .expect("turn completes");
4822
4823 assert_eq!(
4824 outcome,
4825 PromptOutcome::Completed("Both files read".to_string())
4826 );
4827 // user, assistant(tool_use a), user(result a), assistant(tool_use b),
4828 // user(result b), assistant(text)
4829 assert_eq!(messages.len(), 6);
4830 let ContentBlock::ToolResult {
4831 content: a_content, ..
4832 } = &messages[2].content[0]
4833 else {
4834 panic!("expected tool_result for a.txt");
4835 };
4836 assert!(a_content.contains("contents-of-a"));
4837 let ContentBlock::ToolResult {
4838 content: b_content, ..
4839 } = &messages[4].content[0]
4840 else {
4841 panic!("expected tool_result for b.txt");
4842 };
4843 assert!(b_content.contains("contents-of-b"));
4844 }
4845
4846 #[tokio::test]
4847 async fn agentic_turn_reports_a_tool_failure_back_to_the_model_and_keeps_going() {
4848 let (_dir, registry) = workspace_registry();
4849
4850 let round1 = ready_stream({
4851 let mut events = tool_use_events(
4852 0,
4853 "call_1",
4854 "File",
4855 r#"{"action":"read","path":"missing.txt"}"#,
4856 );
4857 events.push(StreamEvent::MessageStop);
4858 events
4859 });
4860 let round2 = ready_stream(vec![
4861 text_delta("That file does not exist"),
4862 StreamEvent::MessageStop,
4863 ]);
4864
4865 let scripted = ScriptedStreams::new(vec![round1, round2]);
4866 let mut reader = lines_from("");
4867 let mut out = Vec::new();
4868
4869 let (outcome, messages) = run_agentic_prompt_turn(
4870 AcpTurnContext {
4871 config: &Config::default(),
4872 model: "test-model",
4873 session_id: "sess_1",
4874 tool_registry: &registry,
4875 response_id_policy: JsonRpcResponseIdPolicy::Preserve,
4876 },
4877 vec![Message {
4878 role: Role::User,
4879 content: vec![ContentBlock::Text {
4880 text: "Read missing.txt".to_string(),
4881 cache_control: None,
4882 }],
4883 }],
4884 &mut reader,
4885 &mut out,
4886 |_msgs| scripted.next(),
4887 )
4888 .await
4889 .expect("turn completes even though the tool failed");
4890
4891 assert_eq!(
4892 outcome,
4893 PromptOutcome::Completed("That file does not exist".to_string())
4894 );
4895 let ContentBlock::ToolResult { is_error, .. } = &messages[2].content[0] else {
4896 panic!("expected a tool_result message");
4897 };
4898 assert_eq!(*is_error, Some(true));
4899
4900 let lines = parse_lines(out);
4901 assert!(lines.iter().any(
4902 |v| v["params"]["update"]["sessionUpdate"] == "tool_call_update"
4903 && v["params"]["update"]["status"] == "failed"
4904 ));
4905 }
4906
4907 #[cfg(windows)]
4908 const SLOW_SHELL_COMMAND: &str = "ping -n 6 127.0.0.1 >NUL";
4909 #[cfg(not(windows))]
4910 const SLOW_SHELL_COMMAND: &str = "sleep 5";
4911
4912 #[tokio::test]
4913 async fn tool_batch_cancels_a_running_bash_and_applies_response_id_policy() {
4914 let (_dir, registry) = workspace_registry();
4915 let started = std::time::Instant::now();
4916
4917 let (outcome, messages, _) = execute_one_with_permission_client(
4918 &Config::default(),
4919 &registry,
4920 pending_call("Bash", json!({ "command": SLOW_SHELL_COMMAND })),
4921 PermissionClientScript::AllowThenCancelRunning,
4922 JsonRpcResponseIdPolicy::StringifyNumeric,
4923 None,
4924 )
4925 .await;
4926
4927 assert!(matches!(outcome, ToolBatchOutcome::Cancelled(_)));
4928 assert!(
4929 started.elapsed() < std::time::Duration::from_secs(4),
4930 "Bash cancellation must preempt the five-second command"
4931 );
4932 assert!(
4933 messages
4934 .iter()
4935 .any(|value| value["id"] == "7" && value["result"].is_null()),
4936 "Zed-compatible cancellation response id was not stringified: {messages:?}"
4937 );
4938 }
4939
4940 #[tokio::test]
4941 async fn concurrent_acp_sessions_execute_tools_independently() {
4942 let (dir1, registry1) = workspace_registry();
4943 let (dir2, registry2) = workspace_registry();
4944 std::fs::write(dir1.path().join("f.txt"), "session-one").unwrap();
4945 std::fs::write(dir2.path().join("f.txt"), "session-two").unwrap();
4946
4947 let (result1, result2) = tokio::join!(
4948 registry1.execute_full("File", json!({"action": "read", "path": "f.txt"})),
4949 registry2.execute_full("File", json!({"action": "read", "path": "f.txt"})),
4950 );
4951
4952 assert!(
4953 result1
4954 .expect("session 1 read")
4955 .content
4956 .contains("session-one")
4957 );
4958 assert!(
4959 result2
4960 .expect("session 2 read")
4961 .content
4962 .contains("session-two")
4963 );
4964 }
4965 }
4966
4966 lines RUST