From aa5103ded55b2af334548d6e491e5e6d20baaa97 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 6 Aug 2026 13:15:56 +0700 Subject: [PATCH 01/26] fix(acp): report model context window --- src/acp/service.rs | 49 ++++++++++++++++++++++++++++++---- src/model/catalog.rs | 1 + src/model/discovery.rs | 7 +++++ src/model/effective_catalog.rs | 5 ++++ src/model/extensions/mod.rs | 2 ++ src/model/extensions/ollama.rs | 8 ++++++ src/model/types.rs | 3 +++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/acp/service.rs b/src/acp/service.rs index fd82fda5..14189424 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -293,7 +293,7 @@ impl AcpService { let reasoning = model_reasoning(&config, &models, &provider, &model); let reasoning_selection = reasoning.unwrap_or(crate::model::reasoning::ReasoningEffort::None); - let context_window = model_context_window(&config, &provider, &model); + let context_window = model_context_window(&config, &models, &provider, &model); let agent = config .merged_config .default_agent @@ -474,8 +474,12 @@ impl AcpService { session.provider.clone_from(&model.provider_id); session.model.clone_from(&model.id); session.reasoning = resolved_reasoning(session, session.reasoning_selection); - session.context_window = - model_context_window(&session.config, &session.provider, &session.model); + session.context_window = model_context_window( + &session.config, + &session.models, + &session.provider, + &session.model, + ); Ok(SetSessionConfigOptionResponse::new(session_config_options( session, ))) @@ -551,7 +555,7 @@ impl AcpService { let reasoning = model_reasoning(&config, &models, &provider, &model); let reasoning_selection = reasoning.unwrap_or(crate::model::reasoning::ReasoningEffort::None); - let context_window = model_context_window(&config, &provider, &model); + let context_window = model_context_window(&config, &models, &provider, &model); let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); let session = AcpSession { cwd, @@ -969,7 +973,20 @@ fn model_reasoning_capability( .filter(|capability| !capability.values().is_empty()) } -fn model_context_window(config: &LoadedConfig, provider: &str, model: &str) -> Option { +fn model_context_window( + config: &LoadedConfig, + models: &[crate::model::types::Model], + provider: &str, + model: &str, +) -> Option { + if let Some(context_window) = models + .iter() + .find(|candidate| candidate.provider_id == provider && candidate.id == model) + .and_then(|model| model.context_window) + { + return Some(context_window); + } + let discovery = crate::model::discovery::Discovery::new_with_custom(Some( config.merged_config.custom_providers.clone(), )) @@ -1476,6 +1493,7 @@ mod tests { free: false, local: false, reasoning_options: Vec::new(), + context_window: None, } } @@ -1568,6 +1586,12 @@ mod tests { fn config_with_command(command: crate::command::custom::CustomCommand) -> LoadedConfig { let mut merged_config = crate::config::configuration::MergedConfig::default(); merged_config.commands.push(command); + config_with_merged(merged_config) + } + + fn config_with_merged( + merged_config: crate::config::configuration::MergedConfig, + ) -> LoadedConfig { LoadedConfig { merged_config, raw_merged: serde_json::Value::Null, @@ -1579,6 +1603,10 @@ mod tests { } } + fn empty_config() -> LoadedConfig { + config_with_merged(crate::config::configuration::MergedConfig::default()) + } + fn session_with_config(config: LoadedConfig) -> AcpSession { let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); AcpSession { @@ -1802,6 +1830,17 @@ mod tests { assert!(find_selectable_model(&models, "other/gpt-5").is_err()); } + #[test] + fn resolves_context_window_from_selectable_models() { + let mut model = model("example", "Example", "large-context", "Large Context"); + model.context_window = Some(1_090_000); + + assert_eq!( + model_context_window(&empty_config(), &[model], "example", "large-context"), + Some(1_090_000) + ); + } + #[test] fn preserves_selected_reasoning_effort_when_model_cannot_apply_it() { let model = model("example", "Example", "chat", "Chat"); diff --git a/src/model/catalog.rs b/src/model/catalog.rs index 385fe9c3..121303d7 100644 --- a/src/model/catalog.rs +++ b/src/model/catalog.rs @@ -124,6 +124,7 @@ mod tests { free: false, local: false, reasoning_options: Vec::new(), + context_window: None, }; assert_eq!(model_ref(&model), "openai/gpt-5"); diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 08098f03..58924f3b 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -283,6 +283,7 @@ impl Discovery { free: false, local: false, reasoning_options: Vec::new(), + context_window: custom_model.context_window, }); } } @@ -714,6 +715,11 @@ impl Discovery { free, local: false, reasoning_options: model.reasoning_options.clone(), + context_window: model + .limit + .as_ref() + .map(|limit| limit.context) + .filter(|context| *context > 0), }); } } @@ -882,6 +888,7 @@ mod tests { free: false, local: false, reasoning_options: Vec::new(), + context_window: None, }; let connected_provider_ids = std::collections::HashSet::new(); let configured_provider_ids = diff --git a/src/model/effective_catalog.rs b/src/model/effective_catalog.rs index 890e662a..eb6b2eaa 100644 --- a/src/model/effective_catalog.rs +++ b/src/model/effective_catalog.rs @@ -27,6 +27,8 @@ struct SnapshotModel { free: bool, local: bool, reasoning_options: Vec, + #[serde(default)] + context_window: Option, } impl From for SnapshotModel { @@ -42,6 +44,7 @@ impl From for SnapshotModel { free: model.free, local: model.local, reasoning_options: model.reasoning_options, + context_window: model.context_window, } } } @@ -59,6 +62,7 @@ impl From for Model { free: model.free, local: model.local, reasoning_options: model.reasoning_options, + context_window: model.context_window, } } } @@ -179,6 +183,7 @@ mod tests { free: false, local: false, reasoning_options: Vec::new(), + context_window: None, } } diff --git a/src/model/extensions/mod.rs b/src/model/extensions/mod.rs index 98e4a174..8aa9a999 100644 --- a/src/model/extensions/mod.rs +++ b/src/model/extensions/mod.rs @@ -390,6 +390,7 @@ mod tests { free: true, local: false, reasoning_options: Vec::new(), + context_window: None, }; let paid_model = crate::model::types::Model { id: "gpt-5.3-codex".to_string(), @@ -402,6 +403,7 @@ mod tests { free: false, local: false, reasoning_options: Vec::new(), + context_window: None, }; assert!(ModelExtensions::is_available_without_connection( diff --git a/src/model/extensions/ollama.rs b/src/model/extensions/ollama.rs index f48be640..81ab76fa 100644 --- a/src/model/extensions/ollama.rs +++ b/src/model/extensions/ollama.rs @@ -161,11 +161,19 @@ pub fn model_for_dialog(model: OllamaModel) -> crate::model::types::Model { free: false, local: true, reasoning_options: Vec::new(), + context_window: discovery_model_for_dialog(&model.id) + .and_then(|model| model.limit) + .map(|limit| limit.context) + .filter(|context| *context > 0), id: model.id, name: model.name, } } +fn discovery_model_for_dialog(id: &str) -> Option { + cached_discovery_models().and_then(|models| models.get(id).cloned()) +} + fn cached_discovery_models( ) -> Option> { let models = cache().lock().ok().and_then(|guard| match guard.clone() { diff --git a/src/model/types.rs b/src/model/types.rs index 8f68c903..ec66c640 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -17,6 +17,8 @@ pub struct Model { pub local: bool, /// Mirrors models.dev `reasoning_options`. pub reasoning_options: Vec, + /// Mirrors models.dev `limit.context` when available. + pub context_window: Option, } impl Model { @@ -142,6 +144,7 @@ mod tests { kind: "effort".to_string(), values: vec!["low".to_string()], }], + context_window: Some(128_000), }; let description = model.dialog_description(); From 912c2ccf4133b05cf4b3123df5315ca9bda6b21a Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 6 Aug 2026 13:27:28 +0700 Subject: [PATCH 02/26] fix(config): align provider filter types --- src/config/configuration.rs | 23 +++++++++++------------ src/model/catalog.rs | 5 ++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 7d3ebf2e..c7885eaa 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -4,7 +4,7 @@ use crate::tools::{ use anyhow::{anyhow, Context, Result}; use regex::Regex; use serde_json::Value; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -110,15 +110,15 @@ fn parse_provider_id_set( value: Option<&Value>, diagnostics: &mut ConfigDiagnostics, key: &str, -) -> BTreeSet { +) -> HashSet { let Some(value) = value else { - return BTreeSet::new(); + return HashSet::new(); }; let Some(entries) = value.as_array() else { diagnostics .warnings .push(format!("{key} must be an array of provider IDs")); - return BTreeSet::new(); + return HashSet::new(); }; entries @@ -422,8 +422,8 @@ pub struct MergedConfig { pub agent_permission_rules: HashMap, pub agent_steps: HashMap, pub provider_timeouts: HashMap, - pub enabled_providers: BTreeSet, - pub disabled_providers: BTreeSet, + pub disabled_providers: HashSet, + pub enabled_providers: Option>, pub custom_providers: HashMap, pub notifications: NotificationsConfig, pub images: ImagesConfig, @@ -1260,12 +1260,11 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M ); out.sync_agent_derived_fields(); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); - out.enabled_providers = parse_provider_id_set( - obj.get("enabled_providers") - .or_else(|| obj.get("enabledProviders")), - diagnostics, - "enabled_providers", - ); + let enabled_providers = obj + .get("enabled_providers") + .or_else(|| obj.get("enabledProviders")); + out.enabled_providers = enabled_providers + .map(|value| parse_provider_id_set(Some(value), diagnostics, "enabled_providers")); out.disabled_providers = parse_provider_id_set( obj.get("disabled_providers") .or_else(|| obj.get("disabledProviders")), diff --git a/src/model/catalog.rs b/src/model/catalog.rs index 121303d7..7b2babf6 100644 --- a/src/model/catalog.rs +++ b/src/model/catalog.rs @@ -104,7 +104,10 @@ fn provider_is_enabled( provider_id: &str, ) -> bool { !config.disabled_providers.contains(provider_id) - && (config.enabled_providers.is_empty() || config.enabled_providers.contains(provider_id)) + && config + .enabled_providers + .as_ref() + .is_none_or(|enabled| enabled.contains(provider_id)) } #[cfg(test)] From 0ac446726fbf7e3aa92bc9603fb5e3a8858408ef Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 09:39:27 +0700 Subject: [PATCH 03/26] feat(acp): stream full tool output --- _docs/acp.mdx | 2 +- src/acp/service.rs | 34 +++++++++++++++---- src/tools/aisdk_bridge.rs | 70 ++++++++++++++++++++++++++++----------- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index c427019d..20424eaf 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -46,7 +46,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Sessions | Create, list, load, resume, close, and fork persisted root sessions. | Replay message IDs are deterministic per load, not durable message IDs. | Persist stable message IDs across streaming snapshots and reloads. | | Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | -| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, and preview output. | No normalized locations, diffs, full outputs, or result images yet. | Preserve structured tool results, locations, patches, and image content in runtime events. | +| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, and bounded preview output. | No normalized locations, diffs, or result images yet. | Preserve structured tool locations, patches, and image content in runtime events. | | Permissions | Existing Crabcode permission prompts are forwarded with allow once, always allow, and reject choices. | Permission requests use a generated ACP call ID because the current internal prompt lacks the originating tool-call ID. | Carry the real tool-call ID and edit patch metadata through permission preflight. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable. | Provider stop reasons are currently reduced to normal completion, cancellation, or a safe failure. | Preserve output-limit and refusal stop reasons from the model runtime. | | Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 1277e7e2..dc90bc4b 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -1154,6 +1154,16 @@ fn send_text( .map_err(|_| internal_error()) } +fn tool_result_text(payload: &serde_json::Value) -> String { + payload + .get("output") + .or_else(|| payload.get("output_preview")) + .or_else(|| payload.get("error")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() +} + fn send_tool_call( connection: &ConnectionTo, session_id: &str, @@ -1247,12 +1257,7 @@ fn send_tool_result( Some("ok") => ToolCallStatus::Completed, _ => ToolCallStatus::Failed, }; - let text = payload - .get("output_preview") - .or_else(|| payload.get("error")) - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); + let text = tool_result_text(&payload); let fields = ToolCallUpdateFields::new() .status(status) .content((!text.is_empty()).then(|| vec![ToolCallContent::from(text)])) @@ -1514,6 +1519,23 @@ mod tests { ); } + #[test] + fn acp_tool_result_prefers_full_output() { + let payload = serde_json::json!({ + "output": "complete tool output", + "output_preview": "short preview", + }); + + assert_eq!(tool_result_text(&payload), "complete tool output"); + } + + #[test] + fn acp_tool_result_supports_legacy_preview_payloads() { + let payload = serde_json::json!({"output_preview": "legacy output"}); + + assert_eq!(tool_result_text(&payload), "legacy output"); + } + #[test] fn flattens_text_and_embedded_context() { let text = prompt_text(vec![ diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 39ce65b5..2d20892e 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -228,24 +228,7 @@ pub async fn convert_to_aisdk_tools( }; if let Some(ref sender) = sender { - let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); - - let line_count = tool_result.output.lines().count(); - let meta = serde_json::Value::Object( - tool_result - .metadata - .into_iter() - .collect::>(), - ); - - let payload = serde_json::json!({ - "status": "ok", - "title": tool_result.title, - "output_preview": preview, - "line_count": line_count, - "metadata": meta, - }) - .to_string(); + let payload = tool_success_payload(&tool_result); if sender .send(crate::llm::ChunkMessage::ToolResult( @@ -334,6 +317,27 @@ fn truncate_tool_output(output: &str, limit: usize) -> String { truncated } +fn tool_success_payload(tool_result: &crate::tools::ToolResult) -> String { + let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); + let meta = serde_json::Value::Object( + tool_result + .metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + ); + + serde_json::json!({ + "status": "ok", + "title": tool_result.title, + "output": tool_result.output, + "output_preview": preview, + "line_count": tool_result.output.lines().count(), + "metadata": meta, + }) + .to_string() +} + fn unsupported_image_input_note(image_count: usize) -> String { let image_label = if image_count == 1 { "image" } else { "images" }; format!( @@ -355,6 +359,7 @@ fn send_tool_error_result( let payload = serde_json::json!({ "status": "error", "title": "Tool failed", + "output": error, "output_preview": preview, "line_count": error.lines().count().max(1), "metadata": { @@ -401,7 +406,8 @@ fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json: #[cfg(test)] mod tests { - use super::{send_tool_error_result, truncate_tool_output}; + use super::{send_tool_error_result, tool_success_payload, truncate_tool_output}; + use std::collections::HashMap; #[test] fn truncate_tool_output_bounds_large_results() { @@ -420,6 +426,28 @@ mod tests { assert_eq!(truncate_tool_output(output, 40_000), output); } + #[test] + fn tool_success_payload_retains_full_output_and_bounded_preview() { + let output = "a".repeat(5_000); + let result = crate::tools::ToolResult { + title: "Large result".to_string(), + output: output.clone(), + metadata: HashMap::new(), + images: Vec::new(), + }; + + let payload: serde_json::Value = + serde_json::from_str(&tool_success_payload(&result)).expect("payload should be json"); + + assert_eq!(payload["output"], output); + assert!(payload["output_preview"] + .as_str() + .is_some_and(|preview| preview.len() < 5_000)); + assert!(payload["output_preview"] + .as_str() + .is_some_and(|preview| preview.contains("tool output truncated to 4000 bytes"))); + } + #[test] fn send_tool_error_result_emits_error_payload() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); @@ -443,6 +471,10 @@ mod tests { serde_json::from_str(&result.content).expect("payload should be json"); assert_eq!(payload["status"], "error"); assert_eq!(payload["title"], "Tool failed"); + assert_eq!( + payload["output"], + "Execution error: Could not find text to replace" + ); assert_eq!( payload["output_preview"], "Execution error: Could not find text to replace" From e44c50df5593c83c5c8a71f0e80940d42ee06835 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 09:50:01 +0700 Subject: [PATCH 04/26] fix(acp): preserve permission tool call IDs --- _docs/acp.mdx | 2 +- src/acp/service.rs | 18 ++++++++++++- src/app.rs | 3 +++ src/tools/aisdk_bridge.rs | 8 +++++- src/tools/permission.rs | 46 ++++++++++++++++++++++++++++++++++ src/views/permission_dialog.rs | 7 ++++++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 20424eaf..4e29c67c 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -47,7 +47,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, and bounded preview output. | No normalized locations, diffs, or result images yet. | Preserve structured tool locations, patches, and image content in runtime events. | -| Permissions | Existing Crabcode permission prompts are forwarded with allow once, always allow, and reject choices. | Permission requests use a generated ACP call ID because the current internal prompt lacks the originating tool-call ID. | Carry the real tool-call ID and edit patch metadata through permission preflight. | +| Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable. | Provider stop reasons are currently reduced to normal completion, cancellation, or a safe failure. | Preserve output-limit and refusal stop reasons from the model runtime. | | Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | diff --git a/src/acp/service.rs b/src/acp/service.rs index dc90bc4b..4a46ab11 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -25,6 +25,12 @@ pub struct AcpService { session_manager: Arc>, } +fn permission_tool_call_id(tool_call_id: Option<&str>) -> String { + tool_call_id + .map(str::to_string) + .unwrap_or_else(|| format!("permission:{}", cuid2::create_id())) +} + fn resolved_reasoning( session: &AcpSession, requested: crate::model::reasoning::ReasoningEffort, @@ -1188,7 +1194,7 @@ async fn request_permission( session_id: &str, prompt: &crate::tools::PermissionPrompt, ) -> crate::tools::PermissionResponse { - let tool_call_id = format!("permission:{}", cuid2::create_id()); + let tool_call_id = permission_tool_call_id(prompt.tool_call_id.as_deref()); let input = serde_json::json!({ "tool": prompt.tool_id, "permission": prompt.permission, @@ -1536,6 +1542,16 @@ mod tests { assert_eq!(tool_result_text(&payload), "legacy output"); } + #[test] + fn acp_permission_prefers_originating_tool_call_id() { + assert_eq!(permission_tool_call_id(Some("call_123")), "call_123"); + } + + #[test] + fn acp_permission_generates_fallback_id_without_origin() { + assert!(permission_tool_call_id(None).starts_with("permission:")); + } + #[test] fn flattens_text_and_embedded_context() { let text = prompt_text(vec![ diff --git a/src/app.rs b/src/app.rs index b2132163..49478136 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12467,6 +12467,7 @@ mod tests { let mut app = test_app(); let (permission_tx, _permission_rx) = tokio::sync::oneshot::channel(); app.permission_dialog_state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "list".to_string(), action: PermissionAction::List, permission: "external_directory".to_string(), @@ -12498,6 +12499,7 @@ mod tests { let mut app = test_app(); let (permission_tx, _permission_rx) = tokio::sync::oneshot::channel(); app.permission_dialog_state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "list".to_string(), action: PermissionAction::List, permission: "external_directory".to_string(), @@ -13123,6 +13125,7 @@ mod tests { app.chat_state.chat.scroll_offset = 0; let (permission_tx, _permission_rx) = tokio::sync::oneshot::channel(); app.permission_dialog_state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "list".to_string(), action: PermissionAction::List, permission: "external_directory".to_string(), diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 2d20892e..1e835844 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -146,7 +146,13 @@ pub async fn convert_to_aisdk_tools( } if let Err(e) = permissions - .preflight(&agent_mode, &tool_id_for_exec, &input, sender.as_ref()) + .preflight_for_call( + &agent_mode, + &tool_id_for_exec, + &input, + Some(&call_id), + sender.as_ref(), + ) .await { let err = format!("{}", e); diff --git a/src/tools/permission.rs b/src/tools/permission.rs index f5706135..9a2d76be 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -98,6 +98,7 @@ pub type PermissionRules = Vec; #[derive(Debug)] pub struct PermissionPrompt { + pub tool_call_id: Option, pub tool_id: String, pub action: PermissionAction, pub permission: String, @@ -323,6 +324,18 @@ impl ToolPermissions { tool_id: &str, params: &Value, sender: Option<&ChunkSender>, + ) -> Result<(), ToolError> { + self.preflight_for_call(agent_mode, tool_id, params, None, sender) + .await + } + + pub async fn preflight_for_call( + &self, + agent_mode: &str, + tool_id: &str, + params: &Value, + tool_call_id: Option<&str>, + sender: Option<&ChunkSender>, ) -> Result<(), ToolError> { if !self.is_tool_allowed_for_agent(agent_mode, tool_id) { return Err(ToolError::Permission(format!( @@ -363,6 +376,7 @@ impl ToolPermissions { PermissionReasonKind::ConfiguredAsk, path.as_deref(), command.clone(), + tool_call_id, sender, ) .await; @@ -407,6 +421,7 @@ impl ToolPermissions { reason_kind, reason_path.as_deref().or(path.as_deref()), command.clone(), + tool_call_id, sender, ) .await; @@ -442,6 +457,7 @@ impl ToolPermissions { reason_kind, path.as_deref(), command, + tool_call_id, sender, ) .await; @@ -459,6 +475,7 @@ impl ToolPermissions { reason_kind: PermissionReasonKind, path: Option<&Path>, command: Option, + tool_call_id: Option<&str>, sender: Option<&ChunkSender>, ) -> Result<(), ToolError> { let target = path @@ -515,6 +532,7 @@ impl ToolPermissions { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); let prompt = PermissionPrompt { + tool_call_id: tool_call_id.map(str::to_string), tool_id: tool_id.to_string(), action, permission: grant.permission.clone(), @@ -1291,6 +1309,34 @@ mod tests { ); } + #[tokio::test] + async fn permission_prompt_retains_originating_tool_call_id() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let tx = tx.clone(); + async move { + perms + .preflight_for_call("build", "read", ¶ms, Some("call_123"), Some(&tx)) + .await + } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + assert_eq!(prompt.tool_call_id.as_deref(), Some("call_123")); + let _ = prompt.response_tx.send(PermissionResponse::Deny); + assert!(pending + .await + .expect("preflight task should complete") + .is_err()); + } + #[tokio::test] async fn allow_always_persists_for_same_request_fingerprint() { let perms = ToolPermissions::new("/tmp/workspace"); diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs index 684c21cd..2ab3c787 100644 --- a/src/views/permission_dialog.rs +++ b/src/views/permission_dialog.rs @@ -556,6 +556,7 @@ mod tests { fn bash_detail_lines_show_command_and_workdir() { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let prompt = PermissionPrompt { + tool_call_id: None, tool_id: "bash".to_string(), action: PermissionAction::Bash, permission: "bash".to_string(), @@ -589,6 +590,7 @@ mod tests { fn external_directory_detail_target_shows_wildcard_scope() { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let prompt = PermissionPrompt { + tool_call_id: None, tool_id: "read".to_string(), action: PermissionAction::Read, permission: "external_directory".to_string(), @@ -614,6 +616,7 @@ mod tests { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let mut state = PermissionDialogState::new(); state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "bash".to_string(), action: PermissionAction::Bash, permission: "bash".to_string(), @@ -658,6 +661,7 @@ mod tests { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let mut state = PermissionDialogState::new(); state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "read".to_string(), action: PermissionAction::Read, permission: "read".to_string(), @@ -689,6 +693,7 @@ mod tests { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let mut state = PermissionDialogState::new(); state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "read".to_string(), action: PermissionAction::Read, permission: "external_directory".to_string(), @@ -729,6 +734,7 @@ mod tests { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let mut state = PermissionDialogState::new(); state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "read".to_string(), action: PermissionAction::Read, permission: "read".to_string(), @@ -770,6 +776,7 @@ mod tests { let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); let mut state = PermissionDialogState::new(); state.enqueue(PermissionPrompt { + tool_call_id: None, tool_id: "read".to_string(), action: PermissionAction::Read, permission: "read".to_string(), From 8a2e6d0f059db80852a973d7ee4e8347ba64cde2 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 10:28:38 +0700 Subject: [PATCH 05/26] feat(acp): stream structured tool content --- _docs/acp.mdx | 2 +- src/acp/service.rs | 230 +++++++++++++++++++++++++++++++++++--- src/tools/aisdk_bridge.rs | 16 +++ src/tools/edit.rs | 5 +- src/tools/fs/write.rs | 16 ++- 5 files changed, 252 insertions(+), 17 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 4e29c67c..38626068 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -46,7 +46,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Sessions | Create, list, load, resume, close, and fork persisted root sessions. | Replay message IDs are deterministic per load, not durable message IDs. | Persist stable message IDs across streaming snapshots and reloads. | | Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | -| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, and bounded preview output. | No normalized locations, diffs, or result images yet. | Preserve structured tool locations, patches, and image content in runtime events. | +| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for `edit`, `write`, and `write_files`. | `apply_patch` exposes affected locations but not native ACP diff content because the current ACP diff shape requires complete old and new file text. | Preserve structured before/after content for patch operations and richer MCP tool results. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable. | Provider stop reasons are currently reduced to normal completion, cancellation, or a safe failure. | Preserve output-limit and refusal stop reasons from the model runtime. | | Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 4a46ab11..0c00d84b 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -7,8 +7,9 @@ use agent_client_protocol::schema::v1::{ RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionResponse, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, SessionMode, SessionModeState, SessionNotification, SessionUpdate, - SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallStatus, - ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate, + SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, + UsageUpdate, }; use agent_client_protocol::{Client, ConnectionTo, Error}; use base64::Engine as _; @@ -383,11 +384,11 @@ impl AcpService { cwd: PathBuf, connection: ConnectionTo, ) -> Result { - let (_session, messages) = self.attach_persisted_session(&session_id, cwd).await?; - replay_messages(&connection, &session_id, &messages)?; + let (session, messages) = self.attach_persisted_session(&session_id, cwd).await?; + replay_messages(&connection, &session_id, &messages, &session.cwd)?; Ok(LoadSessionResponse::new() - .modes(session_modes(&_session)) - .config_options(session_config_options(&_session))) + .modes(session_modes(&session)) + .config_options(session_config_options(&session))) } pub async fn resume_session( @@ -729,7 +730,7 @@ impl AcpService { } crate::llm::ChunkMessage::ToolCalls(tool_calls) => { for tool_call in tool_calls { - send_tool_call(&connection, &session_id, tool_call)?; + send_tool_call(&connection, &session_id, tool_call, &session.cwd)?; } } crate::llm::ChunkMessage::ToolResult(result) => { @@ -738,7 +739,7 @@ impl AcpService { "name": result.name, "content": result.content, })); - send_tool_result(&connection, &session_id, result)?; + send_tool_result(&connection, &session_id, result, &session.cwd)?; } crate::llm::ChunkMessage::Metrics { token_count, @@ -1170,10 +1171,129 @@ fn tool_result_text(payload: &serde_json::Value) -> String { .to_string() } +fn absolute_tool_path(path: &str, cwd: &Path) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + cwd.join(path) + } +} + +fn tool_locations(tool_name: &str, input: &serde_json::Value, cwd: &Path) -> Vec { + let paths = if tool_name == "write_files" { + input + .get("files") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|file| file.get("file_path").and_then(serde_json::Value::as_str)) + .map(str::to_string) + .collect() + } else if tool_name == "apply_patch" { + crate::tools::patch::patch_paths_from_params(input) + } else { + input + .get("file_path") + .or_else(|| input.get("filePath")) + .or_else(|| input.get("filepath")) + .or_else(|| input.get("path")) + .and_then(serde_json::Value::as_str) + .map(|path| vec![path.to_string()]) + .unwrap_or_default() + }; + + paths + .into_iter() + .map(|path| ToolCallLocation::new(absolute_tool_path(&path, cwd))) + .collect() +} + +fn tool_result_locations(payload: &serde_json::Value, cwd: &Path) -> Vec { + let Some(metadata) = payload.get("metadata") else { + return Vec::new(); + }; + let line = metadata + .get("line_number") + .and_then(serde_json::Value::as_u64) + .and_then(|line| u32::try_from(line).ok()); + + if let Some(changes) = metadata + .get("changes") + .and_then(serde_json::Value::as_array) + { + return changes + .iter() + .filter_map(|change| change.get("path").and_then(serde_json::Value::as_str)) + .map(|path| ToolCallLocation::new(absolute_tool_path(path, cwd))) + .collect(); + } + + metadata + .get("path") + .and_then(serde_json::Value::as_str) + .map(|path| ToolCallLocation::new(absolute_tool_path(path, cwd)).line(line)) + .into_iter() + .collect() +} + +fn tool_result_content(payload: &serde_json::Value, cwd: &Path) -> Vec { + let mut content = Vec::new(); + let text = tool_result_text(payload); + if !text.is_empty() { + content.push(ToolCallContent::from(text)); + } + + if let Some(metadata) = payload.get("metadata") { + if let Some(changes) = metadata + .get("changes") + .and_then(serde_json::Value::as_array) + { + content.extend(changes.iter().filter_map(|change| tool_diff(change, cwd))); + } else if let Some(diff) = tool_diff(metadata, cwd) { + content.push(diff); + } + } + + if let Some(images) = payload.get("images").and_then(serde_json::Value::as_array) { + content.extend(images.iter().filter_map(tool_result_image)); + } + + content +} + +fn tool_diff(change: &serde_json::Value, cwd: &Path) -> Option { + let path = change.get("path")?.as_str()?; + let new_text = change.get("new_text")?.as_str()?; + let old_text = change.get("old_text").and_then(serde_json::Value::as_str); + Some( + agent_client_protocol::schema::v1::Diff::new( + absolute_tool_path(path, cwd), + new_text.to_string(), + ) + .old_text(old_text.map(str::to_string)) + .into(), + ) +} + +fn tool_result_image(image: &serde_json::Value) -> Option { + let data = image.get("data_url")?.as_str()?; + let media_type = image.get("media_type")?.as_str()?; + let encoded = data + .strip_prefix("data:") + .and_then(|value| value.split_once(',')) + .map(|(_, encoded)| encoded) + .unwrap_or(data); + Some(ToolCallContent::from(ContentBlock::Image( + agent_client_protocol::schema::v1::ImageContent::new(encoded, media_type), + ))) +} + fn send_tool_call( connection: &ConnectionTo, session_id: &str, tool_call: crate::llm::ToolCall, + cwd: &Path, ) -> Result<(), Error> { let raw_input = serde_json::from_str(&tool_call.function.arguments) .unwrap_or_else(|_| serde_json::json!({ "arguments": tool_call.function.arguments })); @@ -1182,6 +1302,7 @@ fn send_tool_call( ToolCall::new(tool_call.id, title) .kind(tool_kind(&tool_call.function.name)) .status(ToolCallStatus::Pending) + .locations(tool_locations(&tool_call.function.name, &raw_input, cwd)) .raw_input(raw_input), ); connection @@ -1255,6 +1376,7 @@ fn send_tool_result( connection: &ConnectionTo, session_id: &str, result: crate::llm::ToolCallResult, + cwd: &Path, ) -> Result<(), Error> { let payload = serde_json::from_str::(&result.content).unwrap_or_else( |_| serde_json::json!({ "status": "error", "output_preview": result.content }), @@ -1263,11 +1385,15 @@ fn send_tool_result( Some("ok") => ToolCallStatus::Completed, _ => ToolCallStatus::Failed, }; - let text = tool_result_text(&payload); - let fields = ToolCallUpdateFields::new() + let content = tool_result_content(&payload, cwd); + let locations = tool_result_locations(&payload, cwd); + let mut fields = ToolCallUpdateFields::new() .status(status) - .content((!text.is_empty()).then(|| vec![ToolCallContent::from(text)])) + .content((!content.is_empty()).then_some(content)) .raw_output(payload); + if !locations.is_empty() { + fields = fields.locations(locations); + } let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(result.tool_call_id, fields)); connection .send_notification(SessionNotification::new(session_id.to_string(), update)) @@ -1293,6 +1419,7 @@ fn replay_messages( connection: &ConnectionTo, session_id: &str, messages: &[crate::session::types::Message], + cwd: &Path, ) -> Result<(), Error> { for (message_index, message) in messages.iter().enumerate() { let message_id = format!("{session_id}:message:{message_index}"); @@ -1336,8 +1463,8 @@ fn replay_messages( )?; } } - "tool_call" => replay_tool_call(connection, session_id, part)?, - "tool_result" => replay_tool_result(connection, session_id, part)?, + "tool_call" => replay_tool_call(connection, session_id, part, cwd)?, + "tool_result" => replay_tool_result(connection, session_id, part, cwd)?, _ => {} } } @@ -1374,6 +1501,7 @@ fn replay_tool_call( connection: &ConnectionTo, session_id: &str, part: &crate::session::types::MessagePart, + cwd: &Path, ) -> Result<(), Error> { let Some(tool_call_id) = part.tool_id() else { return Ok(()); @@ -1394,6 +1522,7 @@ fn replay_tool_call( ToolCall::new(tool_call_id.to_string(), tool_title(name, &input)) .kind(tool_kind(name)) .status(status) + .locations(tool_locations(name, &input, cwd)) .raw_input(input), ); connection @@ -1405,6 +1534,7 @@ fn replay_tool_result( connection: &ConnectionTo, session_id: &str, part: &crate::session::types::MessagePart, + cwd: &Path, ) -> Result<(), Error> { let Some(tool_call_id) = part.tool_id() else { return Ok(()); @@ -1424,6 +1554,7 @@ fn replay_tool_result( name: part.tool_name().unwrap_or("tool").to_string(), content, }, + cwd, ) } @@ -1542,6 +1673,79 @@ mod tests { assert_eq!(tool_result_text(&payload), "legacy output"); } + #[test] + fn acp_tool_locations_normalize_multi_file_and_patch_paths() { + let cwd = Path::new("/tmp/workspace"); + let write_locations = tool_locations( + "write_files", + &serde_json::json!({ + "files": [ + {"file_path": "src/a.rs", "content": "a"}, + {"file_path": "/tmp/b.rs", "content": "b"} + ] + }), + cwd, + ); + assert_eq!( + write_locations[0].path, + PathBuf::from("/tmp/workspace/src/a.rs") + ); + assert_eq!(write_locations[1].path, PathBuf::from("/tmp/b.rs")); + + let patch_locations = tool_locations( + "apply_patch", + &serde_json::json!({ + "patch": "*** Begin Patch\n*** Update File: src/a.rs\n*** Add File: src/b.rs\n*** End Patch" + }), + cwd, + ); + assert_eq!(patch_locations.len(), 2); + assert_eq!( + patch_locations[1].path, + PathBuf::from("/tmp/workspace/src/b.rs") + ); + } + + #[test] + fn acp_tool_result_emits_diff_location_and_image_content() { + let payload = serde_json::json!({ + "output": "updated", + "metadata": { + "path": "src/main.rs", + "line_number": 4, + "old_text": "fn old() {}", + "new_text": "fn new() {}" + }, + "images": [{ + "data_url": "data:image/png;base64,aGk=", + "media_type": "image/png" + }] + }); + let cwd = Path::new("/tmp/workspace"); + + let locations = tool_result_locations(&payload, cwd); + assert_eq!( + locations[0].path, + PathBuf::from("/tmp/workspace/src/main.rs") + ); + assert_eq!(locations[0].line, Some(4)); + + let content = tool_result_content(&payload, cwd); + let diff = content.iter().find_map(|item| match item { + ToolCallContent::Diff(diff) => Some(diff), + _ => None, + }); + let diff = diff.expect("diff content"); + assert_eq!(diff.path, PathBuf::from("/tmp/workspace/src/main.rs")); + assert_eq!(diff.old_text.as_deref(), Some("fn old() {}")); + assert_eq!(diff.new_text, "fn new() {}"); + assert!(content.iter().any(|item| matches!( + item, + ToolCallContent::Content(content) + if matches!(&content.content, ContentBlock::Image(image) if image.data == "aGk=" && image.mime_type == "image/png") + ))); + } + #[test] fn acp_permission_prefers_originating_tool_call_id() { assert_eq!(permission_tool_call_id(Some("call_123")), "call_123"); diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 1e835844..22db5238 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -340,6 +340,7 @@ fn tool_success_payload(tool_result: &crate::tools::ToolResult) -> String { "output_preview": preview, "line_count": tool_result.output.lines().count(), "metadata": meta, + "images": tool_result.images, }) .to_string() } @@ -454,6 +455,21 @@ mod tests { .is_some_and(|preview| preview.contains("tool output truncated to 4000 bytes"))); } + #[test] + fn tool_success_payload_retains_result_images() { + let result = crate::tools::ToolResult::new("Image", "viewed") + .with_image("data:image/png;base64,aGk=", "image/png"); + + let payload: serde_json::Value = + serde_json::from_str(&tool_success_payload(&result)).expect("payload should be json"); + + assert_eq!( + payload["images"][0]["data_url"], + "data:image/png;base64,aGk=" + ); + assert_eq!(payload["images"][0]["media_type"], "image/png"); + } + #[test] fn send_tool_error_result_emits_error_payload() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/tools/edit.rs b/src/tools/edit.rs index e37a732c..70eb658d 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -155,7 +155,10 @@ impl ToolHandler for EditTool { format!("Replaced at line {}", line_num.unwrap_or(1)) }, ) - .with_metadata("replace_count", serde_json::json!(count)); + .with_metadata("replace_count", serde_json::json!(count)) + .with_metadata("path", serde_json::json!(file_path)) + .with_metadata("old_text", serde_json::json!(content)) + .with_metadata("new_text", serde_json::json!(new_content)); if let Some(line_num) = line_num { result = result.with_metadata("line_number", serde_json::json!(line_num)); diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs index 2ab70d39..2dc7caf1 100644 --- a/src/tools/fs/write.rs +++ b/src/tools/fs/write.rs @@ -63,6 +63,7 @@ impl ToolHandler for WriteTool { let content = get_string_param(¶ms, "content") .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; + let old_text = std::fs::read_to_string(&file_path).ok(); let (is_new, bytes) = write_one_file(&file_path, &content)?; Ok(ToolResult::new( @@ -72,7 +73,10 @@ impl ToolHandler for WriteTool { } else { format!("Updated file with {} bytes", bytes) }, - )) + ) + .with_metadata("path", serde_json::json!(file_path)) + .with_metadata("old_text", serde_json::json!(old_text)) + .with_metadata("new_text", serde_json::json!(content))) } } @@ -137,6 +141,7 @@ impl ToolHandler for WriteFilesTool { .ok_or_else(|| ToolError::Validation("files must be an array".to_string()))?; let mut summaries = Vec::with_capacity(files.len()); + let mut changes = Vec::with_capacity(files.len()); for file in files { let file_path = file .get("file_path") @@ -146,16 +151,23 @@ impl ToolHandler for WriteFilesTool { .get("content") .and_then(Value::as_str) .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; + let old_text = std::fs::read_to_string(file_path).ok(); let (is_new, bytes) = write_one_file(file_path, content)?; let action = if is_new { "created" } else { "updated" }; summaries.push(format!("{file_path}: {action} {bytes} bytes")); + changes.push(serde_json::json!({ + "path": file_path, + "old_text": old_text, + "new_text": content, + })); } Ok(ToolResult::new( format!("Write files: {}", summaries.len()), summaries.join("\n"), ) - .with_metadata("file_count", serde_json::json!(summaries.len()))) + .with_metadata("file_count", serde_json::json!(summaries.len())) + .with_metadata("changes", serde_json::json!(changes))) } } From 0416dc33ba505c4bd339a1f1bfc8a2b4590e6045 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 10:40:31 +0700 Subject: [PATCH 06/26] feat(acp): preserve provider stop reasons --- _docs/acp.mdx | 2 +- src/acp/service.rs | 25 +++++++++++++++++++- src/aisdk/README.md | 1 + src/aisdk/providers/anthropic.rs | 39 ++++++++++++++++++++++++------- src/aisdk/providers/compatible.rs | 17 ++++++-------- src/aisdk/providers/openai.rs | 36 +++++++++++++++++++++++++--- src/aisdk/response.rs | 38 +++++++++++++++++++++++++++++- src/aisdk/stop.rs | 2 ++ src/app.rs | 1 + src/llm/client.rs | 23 ++++++++++++++++++ src/llm/mod.rs | 7 ++++++ src/main.rs | 3 ++- 12 files changed, 168 insertions(+), 26 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 38626068..4efdd16e 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -48,7 +48,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for `edit`, `write`, and `write_files`. | `apply_patch` exposes affected locations but not native ACP diff content because the current ACP diff shape requires complete old and new file text. | Preserve structured before/after content for patch operations and richer MCP tool results. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | -| Cancellation | `session/cancel` cancels the active turn and keeps the session reusable. | Provider stop reasons are currently reduced to normal completion, cancellation, or a safe failure. | Preserve output-limit and refusal stop reasons from the model runtime. | +| Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | | Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | | Terminals | — | ACP terminal embedding is not implemented. | Add a client-terminal adapter for long-running shell sessions. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 0c00d84b..3f8b0032 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -717,6 +717,7 @@ impl AcpService { assistant.agent_mode = Some(session.agent.clone()); let mut failed = None; let mut cancelled = false; + let mut turn_stop_reason = None; while let Some(chunk) = receiver.recv().await { match chunk { @@ -756,6 +757,7 @@ impl AcpService { } crate::llm::ChunkMessage::Cancelled => cancelled = true, crate::llm::ChunkMessage::Failed(error) => failed = Some(error), + crate::llm::ChunkMessage::TurnStopReason(reason) => turn_stop_reason = Some(reason), crate::llm::ChunkMessage::PermissionRequest(prompt) => { let response = request_permission(&connection, &session_id, &prompt).await; let _ = prompt.response_tx.send(response); @@ -801,7 +803,15 @@ impl AcpService { if let Some(error) = failed { return Err(internal_error_with(&error)); } - Ok(PromptResponse::new(StopReason::EndTurn)) + Ok(PromptResponse::new(acp_stop_reason(turn_stop_reason))) + } +} + +fn acp_stop_reason(reason: Option) -> StopReason { + match reason { + Some(crate::llm::TurnStopReason::MaxTokens) => StopReason::MaxTokens, + Some(crate::llm::TurnStopReason::Refusal) => StopReason::Refusal, + None => StopReason::EndTurn, } } @@ -1756,6 +1766,19 @@ mod tests { assert!(permission_tool_call_id(None).starts_with("permission:")); } + #[test] + fn maps_typed_turn_stop_reasons_to_acp() { + assert_eq!( + acp_stop_reason(Some(crate::llm::TurnStopReason::MaxTokens)), + StopReason::MaxTokens + ); + assert_eq!( + acp_stop_reason(Some(crate::llm::TurnStopReason::Refusal)), + StopReason::Refusal + ); + assert_eq!(acp_stop_reason(None), StopReason::EndTurn); + } + #[test] fn flattens_text_and_embedded_context() { let text = prompt_text(vec![ diff --git a/src/aisdk/README.md b/src/aisdk/README.md index 8ba7f3cf..2f9197f0 100644 --- a/src/aisdk/README.md +++ b/src/aisdk/README.md @@ -37,5 +37,6 @@ Done for packaging/host hooks: - Absolute `crate::{chunk,error,...}` paths are crate-root-shaped (host re-exports them today) - Product-leaky debug path renamed/feature-gated - Product-flavored comments/tests scrubbed +- Typed terminal stop reasons include normal completion, max tokens, refusal, hooks, and errors Keep app glue outside this tree (`src/tools/aisdk_bridge.rs`, `src/llm/*`). diff --git a/src/aisdk/providers/anthropic.rs b/src/aisdk/providers/anthropic.rs index c34c5858..6eb5f53a 100644 --- a/src/aisdk/providers/anthropic.rs +++ b/src/aisdk/providers/anthropic.rs @@ -337,13 +337,9 @@ fn anthropic_message_delta(value: &serde_json::Value) -> Option { .and_then(|delta| delta.get("stop_reason")) .and_then(|stop_reason| stop_reason.as_str())?; - match stop_reason { - "max_tokens" => Some(ChunkType::Incomplete("stop_reason=max_tokens".to_string())), - "refusal" => Some(ChunkType::Failed("stop_reason=refusal".to_string())), - reason => Some(ChunkType::End { - reason: Some(FinishReason::from_anthropic(reason)), - }), - } + Some(ChunkType::End { + reason: Some(FinishReason::from_anthropic(stop_reason)), + }) } fn anthropic_hosted_search_start(value: &serde_json::Value) -> Option { @@ -810,7 +806,7 @@ mod tests { } #[test] - fn max_tokens_stop_reason_emits_incomplete_chunk() { + fn max_tokens_stop_reason_emits_terminal_reason() { let value = serde_json::json!({ "type": "message_delta", "delta": { @@ -821,7 +817,32 @@ mod tests { .expect("event should produce a chunk") .expect("chunk should parse"); - assert!(matches!(chunk, ChunkType::Incomplete(_))); + assert!(matches!( + chunk, + ChunkType::End { + reason: Some(FinishReason::Length) + } + )); + } + + #[test] + fn refusal_stop_reason_emits_terminal_reason() { + let value = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": "refusal", + }, + }); + let chunk = anthropic_stream_chunk("message_delta", &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!( + chunk, + ChunkType::End { + reason: Some(FinishReason::Refusal) + } + )); } #[test] diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index bbd0aa32..c3473c45 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -604,12 +604,6 @@ fn process_sse_data(data: &str) -> Vec> { match finish_reason { "" => {} - "length" => chunks.push(Ok(ChunkType::Incomplete( - "finish_reason=length".to_string(), - ))), - "content_filter" => chunks.push(Ok(ChunkType::Failed( - "finish_reason=content_filter".to_string(), - ))), _ => chunks.push(Ok(ChunkType::End { reason: Some(FinishReason::from_openai_compatible(finish_reason)), })), @@ -780,14 +774,17 @@ mod tests { } #[test] - fn length_finish_reason_emits_incomplete_chunk() { + fn length_finish_reason_emits_terminal_reason() { let data = r#"{"choices":[{"index":0,"finish_reason":"length","delta":{"role":"assistant","content":""}}]}"#; let chunks = process_sse_data(data); - assert!(chunks - .iter() - .any(|chunk| matches!(chunk, Ok(ChunkType::Incomplete(_))))); + assert!(chunks.iter().any(|chunk| matches!( + chunk, + Ok(ChunkType::End { + reason: Some(FinishReason::Length) + }) + ))); } #[test] diff --git a/src/aisdk/providers/openai.rs b/src/aisdk/providers/openai.rs index 45e60fe1..b71475a9 100644 --- a/src/aisdk/providers/openai.rs +++ b/src/aisdk/providers/openai.rs @@ -43,6 +43,23 @@ pub trait HttpResponseRetryPolicy: Send + Sync + std::fmt::Debug { ) -> Option; } +fn responses_incomplete_chunk(value: &serde_json::Value) -> ChunkType { + let reason = value + .get("response") + .and_then(|response| response.get("incomplete_details")) + .and_then(|details| details.get("reason")) + .and_then(serde_json::Value::as_str); + if matches!(reason, Some("max_output_tokens" | "max_tokens")) { + ChunkType::End { + reason: Some(crate::chunk::FinishReason::Length), + } + } else { + ChunkType::RetryableFailure(RetryError::from_message(responses_incomplete_message( + value, + ))) + } +} + #[derive(Debug, Clone)] pub struct OpenAI { base_url: String, @@ -1565,9 +1582,7 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { "doom_loop_check triggers={triggers}" )))) } - "response.incomplete" => Some(Ok(ChunkType::RetryableFailure(RetryError::from_message( - responses_incomplete_message(&value), - )))), + "response.incomplete" => Some(Ok(responses_incomplete_chunk(&value))), "response.failed" | "error" => Some(Ok(responses_error_chunk(&value, event_type))), _ => { if let Some(reasoning_item) = responses_reasoning_item_chunk(&value) { @@ -2493,6 +2508,21 @@ mod tests { )); } + #[test] + fn response_incomplete_max_output_tokens_emits_terminal_reason() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#, + ) + .expect("expected incomplete chunk"); + + assert!(matches!( + chunk, + Ok(ChunkType::End { + reason: Some(crate::chunk::FinishReason::Length) + }) + )); + } + #[test] fn retryable_failure_is_terminal_for_sse_eof_tracking() { let chunk = Ok(ChunkType::RetryableFailure( diff --git a/src/aisdk/response.rs b/src/aisdk/response.rs index cef392f7..6377ddc5 100644 --- a/src/aisdk/response.rs +++ b/src/aisdk/response.rs @@ -13,6 +13,21 @@ use tokio::sync::mpsc; const PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT: usize = 1; const PROVIDER_STEP_MAX_RETRIES: usize = 10; + +fn terminal_stop_reason(reason: Option<&FinishReason>) -> StopReason { + match reason { + Some(FinishReason::Length) => StopReason::MaxTokens, + Some(FinishReason::Refusal | FinishReason::ContentFilter) => StopReason::Refusal, + _ => StopReason::Finish, + } +} + +fn provider_reason_ends_turn(reason: Option<&FinishReason>) -> bool { + matches!( + reason, + Some(FinishReason::Length | FinishReason::Refusal | FinishReason::ContentFilter) + ) +} /// Grok Build only acts on `tail_repetition:{n}@thinking` from /// `response.doom_loop_check` — never on tool names across steps. /// `.devrefs/references/xai-org/grok-build/crates/codegen/xai-grok-sampler/src/doom_loop.rs` @@ -768,6 +783,7 @@ pub async fn stream_with_tools( && response_end_turn.is_none() && last_assistant_message_phase.is_none() && phase_less_ambiguous_follow_ups < PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT + && !provider_reason_ends_turn(provider_finish_reason.as_ref()) && provider_finish_reason .as_ref() .is_some_and(|reason| !reason.is_final_assistant_stop()); @@ -808,7 +824,8 @@ pub async fn stream_with_tools( ))); continue; } - *stop_reason_arc.lock().await = Some(StopReason::Finish); + *stop_reason_arc.lock().await = + Some(terminal_stop_reason(provider_finish_reason.as_ref())); break; } @@ -4478,3 +4495,22 @@ mod tests { assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); } } +#[test] +fn terminal_provider_reasons_map_to_typed_stop_reasons() { + assert_eq!( + terminal_stop_reason(Some(&FinishReason::Length)), + StopReason::MaxTokens + ); + assert_eq!( + terminal_stop_reason(Some(&FinishReason::Refusal)), + StopReason::Refusal + ); + assert_eq!( + terminal_stop_reason(Some(&FinishReason::ContentFilter)), + StopReason::Refusal + ); + assert_eq!( + terminal_stop_reason(Some(&FinishReason::Stop)), + StopReason::Finish + ); +} diff --git a/src/aisdk/stop.rs b/src/aisdk/stop.rs index bbcf071d..c84d3d9f 100644 --- a/src/aisdk/stop.rs +++ b/src/aisdk/stop.rs @@ -3,6 +3,8 @@ use std::sync::Arc; #[derive(Debug, Clone, PartialEq)] pub enum StopReason { Finish, + MaxTokens, + Refusal, Hook, Error(String), Other(String), diff --git a/src/app.rs b/src/app.rs index 49478136..cfd7bb4c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9741,6 +9741,7 @@ impl App { false } crate::llm::ChunkMessage::Metrics { .. } => true, + crate::llm::ChunkMessage::TurnStopReason(_) => true, crate::llm::ChunkMessage::ToolCalls(tool_calls) => { self.set_session_retry_status(session_id, None); // Close the generation sample as a tool-calls finish (excluded from diff --git a/src/llm/client.rs b/src/llm/client.rs index ff30a074..f0ef3106 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -48,6 +48,14 @@ struct ProviderRequestConfig { gateway_caching_auto: bool, } +fn turn_stop_reason(stop_reason: Option<&StopReason>) -> Option { + match stop_reason { + Some(StopReason::MaxTokens) => Some(crate::llm::TurnStopReason::MaxTokens), + Some(StopReason::Refusal) => Some(crate::llm::TurnStopReason::Refusal), + _ => None, + } +} + impl ProviderRequestConfig { fn new( kind: ProviderKind, @@ -767,6 +775,9 @@ pub async fn stream_llm_with_cancellation( }; let stop_reason = response.stop_reason().await; + if let Some(reason) = turn_stop_reason(stop_reason.as_ref()) { + let _ = sender.send(crate::llm::ChunkMessage::TurnStopReason(reason)); + } let stream_outcome = relay_result.outcome; let primary_outcome_label = stream_outcome_label(stream_outcome, stop_reason.as_ref()); crate::emit_log!( @@ -3763,3 +3774,15 @@ fn content_with_vlm_agent_hint(content: &str, image_paths: &[String]) -> String format!("{content}\n\n{hint}") } } +#[test] +fn maps_runtime_stop_reasons_to_turn_events() { + assert_eq!( + turn_stop_reason(Some(&StopReason::MaxTokens)), + Some(crate::llm::TurnStopReason::MaxTokens) + ); + assert_eq!( + turn_stop_reason(Some(&StopReason::Refusal)), + Some(crate::llm::TurnStopReason::Refusal) + ); + assert_eq!(turn_stop_reason(Some(&StopReason::Finish)), None); +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 780d79d3..e9df1e13 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -47,6 +47,7 @@ pub enum ChunkMessage { job_id: String, event: BackgroundJobEventKind, }, + TurnStopReason(TurnStopReason), End, Failed(String), Cancelled, @@ -56,6 +57,12 @@ pub enum ChunkMessage { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnStopReason { + MaxTokens, + Refusal, +} + #[derive(Debug, Clone)] pub enum BackgroundJobEventKind { Started { diff --git a/src/main.rs b/src/main.rs index c48eefb9..00c1f7f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -558,7 +558,8 @@ async fn run_print_mode( | crate::llm::ChunkMessage::SubagentStarted { .. } | crate::llm::ChunkMessage::SubagentChunk { .. } | crate::llm::ChunkMessage::TerminalSessionEvent { .. } - | crate::llm::ChunkMessage::BackgroundJobEvent { .. } => {} + | crate::llm::ChunkMessage::BackgroundJobEvent { .. } + | crate::llm::ChunkMessage::TurnStopReason(_) => {} crate::llm::ChunkMessage::End => { println!(); play_resolved_sound(&sounds, crate::sound::SoundEvent::Complete); From a44d28653488a117977a516f8bd2358ea00fe3f4 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 10:44:01 +0700 Subject: [PATCH 07/26] feat(acp): stream apply patch diffs --- _docs/acp.mdx | 2 +- src/tools/patch.rs | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 4efdd16e..e772ee29 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -46,7 +46,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Sessions | Create, list, load, resume, close, and fork persisted root sessions. | Replay message IDs are deterministic per load, not durable message IDs. | Persist stable message IDs across streaming snapshots and reloads. | | Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | -| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for `edit`, `write`, and `write_files`. | `apply_patch` exposes affected locations but not native ACP diff content because the current ACP diff shape requires complete old and new file text. | Preserve structured before/after content for patch operations and richer MCP tool results. | +| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | | Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | diff --git a/src/tools/patch.rs b/src/tools/patch.rs index eb431702..b03844a5 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -75,21 +75,40 @@ impl ToolHandler for ApplyPatchTool { Ok(()) } - async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { let patch = get_string_param(¶ms, "patch") .ok_or_else(|| ToolError::Validation("patch is required".to_string()))?; let patch = clean_patch_input(&patch); + let paths = patch_paths_as_pathbufs(¶ms, ctx.workdir()); + let before = paths + .iter() + .map(|path| (path.clone(), std::fs::read_to_string(path).ok())) + .collect::>(); let summary = if patch.trim_start().starts_with("*** Begin Patch") { apply_codex_patch(&patch)? } else { apply_unified_patch(&patch)? }; + let changes = before + .into_iter() + .filter_map(|(path, old_text)| { + let new_text = std::fs::read_to_string(&path).ok(); + (old_text != new_text).then(|| { + serde_json::json!({ + "path": path, + "old_text": old_text, + "new_text": new_text.unwrap_or_default(), + }) + }) + }) + .collect::>(); Ok(ToolResult::new( "Apply patch", format!("Applied patch: {}", summary.describe()), ) - .with_metadata("file_count", serde_json::json!(summary.touched()))) + .with_metadata("file_count", serde_json::json!(summary.touched())) + .with_metadata("changes", serde_json::json!(changes))) } } @@ -718,6 +737,12 @@ mod tests { assert_eq!(std::fs::read_to_string(second).unwrap(), "alpha\ngamma\n"); assert!(result.output.contains("updated 2")); assert_eq!(result.metadata["file_count"], serde_json::json!(2)); + let changes = result.metadata["changes"] + .as_array() + .expect("patch changes"); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0]["old_text"], "one\ntwo\n"); + assert_eq!(changes[0]["new_text"], "one\nthree\n"); } #[tokio::test] From 62ece17fa09458e6f834081e38c8962507fd2f49 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 10:52:16 +0700 Subject: [PATCH 08/26] feat(acp): preserve stable message IDs --- _docs/acp.mdx | 2 +- src/acp/service.rs | 6 +++--- src/persistence/conversions.rs | 15 ++++++++++++++- src/session/types.rs | 3 +++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index e772ee29..0e3a46d8 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -43,7 +43,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Area | Supported behavior | Current limitations | Planned follow-up | | --- | --- | --- | --- | | Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown. | stdout must remain protocol-only. | Add protocol-level subprocess integration coverage. | -| Sessions | Create, list, load, resume, close, and fork persisted root sessions. | Replay message IDs are deterministic per load, not durable message IDs. | Persist stable message IDs across streaming snapshots and reloads. | +| Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | | Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 3f8b0032..fce19111 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -710,8 +710,8 @@ impl AcpService { let _ = stream_sender.send(crate::llm::ChunkMessage::End); }); - let message_id = cuid2::create_id(); let mut assistant = crate::session::types::Message::incomplete(""); + let message_id = assistant.id.clone(); assistant.provider = Some(session.provider.clone()); assistant.model = Some(session.model.clone()); assistant.agent_mode = Some(session.agent.clone()); @@ -1431,8 +1431,8 @@ fn replay_messages( messages: &[crate::session::types::Message], cwd: &Path, ) -> Result<(), Error> { - for (message_index, message) in messages.iter().enumerate() { - let message_id = format!("{session_id}:message:{message_index}"); + for message in messages { + let message_id = message.id.clone(); match message.role { crate::session::types::MessageRole::User => { if !message.content.is_empty() { diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 54dbe02b..8615536d 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -71,7 +71,7 @@ impl From for Message { } Message { - id: cuid2::create_id(), + id: msg.id, session_id: 0, role: match msg.role { MessageRole::User => "user".to_string(), @@ -170,6 +170,7 @@ impl TryFrom for SessionMessage { }; Ok(SessionMessage { + id: msg.id, role, content, reasoning, @@ -231,6 +232,18 @@ pub fn persistence_to_session( mod tests { use super::*; + #[test] + fn message_id_round_trips_through_persistence() { + let session_message = SessionMessage::assistant("hello"); + let id = session_message.id.clone(); + + let persistence_message: Message = session_message.into(); + assert_eq!(persistence_message.id, id); + + let restored = SessionMessage::try_from(persistence_message).unwrap(); + assert_eq!(restored.id, id); + } + #[test] fn compaction_stats_round_trip_through_message_parts() { let stats = CompactionStats { diff --git a/src/session/types.rs b/src/session/types.rs index 2ace65a6..980846b3 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -158,6 +158,7 @@ impl CompactionStats { #[derive(Debug, Clone, PartialEq)] pub struct Message { + pub id: String, pub role: MessageRole, pub content: String, pub reasoning: Option, @@ -194,6 +195,7 @@ impl Message { }; Self { + id: cuid2::create_id(), role, content, reasoning: None, @@ -242,6 +244,7 @@ impl Message { }; Self { + id: cuid2::create_id(), role: MessageRole::Assistant, content, reasoning: None, From 39818cb1a753839f8bbeb9180a9bd64881f2f82e Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 11:03:18 +0700 Subject: [PATCH 09/26] feat(acp): add compact command --- _docs/acp.mdx | 2 +- src/acp/service.rs | 364 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 331 insertions(+), 35 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 0e3a46d8..84aff406 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -49,7 +49,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | -| Commands and skills | Session updates publish available commands: project custom slash commands, workspace skills, plus built-in `/skills` and `/mcp`. Leading `/…` prompts expand through the same command and skill templates before the model turn. | Built-in TUI commands such as `/compact` are not ACP-available commands; unknown `/…` lines pass through as plain text. | Add more built-in commands (for example `/compact`) and richer command input schemas. | +| Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | | Terminals | — | ACP terminal embedding is not implemented. | Add a client-terminal adapter for long-running shell sessions. | | Questions | — | Interactive question prompts from the agent are not forwarded over ACP; the runtime skips them rather than blocking the editor. | Map Crabcode questions to ACP permission-style or dedicated question requests. | diff --git a/src/acp/service.rs b/src/acp/service.rs index fce19111..a34fff81 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -26,6 +26,24 @@ pub struct AcpService { session_manager: Arc>, } +fn compact_command(prompt: &str) -> Result { + let trimmed = prompt.trim(); + let Some(command_line) = trimmed.strip_prefix('/') else { + return Ok(false); + }; + let (name, args) = command_line + .split_once(char::is_whitespace) + .map(|(name, args)| (name, args.trim())) + .unwrap_or((command_line, "")); + if name != "compact" { + return Ok(false); + } + if !args.is_empty() { + return Err(Error::invalid_params().data("Usage: /compact")); + } + Ok(true) +} + fn permission_tool_call_id(tool_call_id: Option<&str>) -> String { tool_call_id .map(str::to_string) @@ -63,6 +81,7 @@ fn available_commands(session: &AcpSession) -> Vec { .merged_config .commands .iter() + .filter(|command| command.name != "compact") .map(|command| { let description = command .description @@ -77,18 +96,25 @@ fn available_commands(session: &AcpSession) -> Vec { available }) .collect(); - commands.extend(session.skills.all().into_iter().map(|skill| { - AvailableCommand::new( - skill.name.clone(), - skill - .description - .clone() - .unwrap_or_else(|| format!("Use the {} skill", skill.name)), - ) - .input(AvailableCommandInput::Unstructured( - UnstructuredCommandInput::new("Task or context for this skill"), - )) - })); + commands.extend( + session + .skills + .all() + .into_iter() + .filter(|skill| skill.name != "compact") + .map(|skill| { + AvailableCommand::new( + skill.name.clone(), + skill + .description + .clone() + .unwrap_or_else(|| format!("Use the {} skill", skill.name)), + ) + .input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new("Task or context for this skill"), + )) + }), + ); commands.push(AvailableCommand::new( "skills", "List skills available in this workspace", @@ -97,6 +123,10 @@ fn available_commands(session: &AcpSession) -> Vec { "mcp", "List configured MCP servers and their status", )); + commands.push(AvailableCommand::new( + "compact", + "Summarize this session to reduce context", + )); commands.sort_by(|left, right| left.name.cmp(&right.name)); commands.dedup_by(|left, right| left.name == right.name); commands @@ -598,6 +628,22 @@ impl AcpService { .iter() .find(|model| model.provider_id == session.provider && model.id == session.model) .is_some_and(|model| model.attachment); + let compact_text = prompt + .iter() + .filter_map(|part| match part { + ContentBlock::Text(content) => Some(content.text.as_str()), + _ => None, + }) + .collect::(); + if compact_command(&compact_text)? { + if prompt + .iter() + .any(|part| !matches!(part, ContentBlock::Text(_))) + { + return Err(Error::invalid_params().data("/compact does not accept attachments")); + } + return self.compact_session(&session_id, session, connection).await; + } let (prompt, local_image_paths) = prompt_content(prompt, supports_images, &session)?; let prompt = expand_slash_command(&session, &prompt).await?; if prompt.trim().is_empty() { @@ -805,6 +851,194 @@ impl AcpService { } Ok(PromptResponse::new(acp_stop_reason(turn_stop_reason))) } + + async fn compact_session( + &self, + session_id: &str, + session: AcpSession, + connection: ConnectionTo, + ) -> Result { + let cancellation = CancellationToken::new(); + { + let mut sessions = self.sessions.lock().await; + let current = sessions + .get_mut(session_id) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + if current.cancellation.is_some() { + return Err(Error::invalid_params().data("session already has an active prompt")); + } + current.cancellation = Some(cancellation.clone()); + } + let status_result = self + .session_manager + .lock() + .map_err(|_| internal_error())? + .set_session_status( + session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + if status_result.is_err() { + if let Some(current) = self.sessions.lock().await.get_mut(session_id) { + current.cancellation = None; + } + return Err(internal_error()); + } + + let result = self + .run_compaction(session_id, &session, cancellation.clone()) + .await; + if let Some(current) = self.sessions.lock().await.get_mut(session_id) { + current.cancellation = None; + } + self.session_manager + .lock() + .map_err(|_| internal_error())? + .set_session_status(session_id, crate::session::types::SessionStatus::Idle, None) + .map_err(|_| internal_error())?; + + match result { + Ok(stats) => { + let feedback = format!( + "Context compacted ({})", + crate::session::compaction::format_compaction_stats(stats) + ); + send_text( + &connection, + session_id, + &cuid2::create_id(), + feedback, + false, + )?; + Ok(PromptResponse::new(StopReason::EndTurn)) + } + Err(_error) if cancellation.is_cancelled() => { + Ok(PromptResponse::new(StopReason::Cancelled)) + } + Err(error) => Err(error), + } + } + + async fn run_compaction( + &self, + session_id: &str, + session: &AcpSession, + cancellation: CancellationToken, + ) -> Result { + let messages = { + let manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .get_session_ref(session_id) + .map(|stored| stored.messages.clone()) + .ok_or_else(|| Error::invalid_params().data("unknown session"))? + }; + let selection = crate::session::compaction::select_messages_for_compaction_with_min( + &messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + 0, + ) + .ok_or_else(|| Error::invalid_params().data("Nothing to compact"))?; + let before_tokens = crate::session::compaction::total_context_tokens(&messages); + let before_messages = + crate::session::compaction::filter_messages_for_context(&messages).len(); + let prompt = crate::session::compaction::build_prompt(&selection.messages_to_summarize); + let summary = crate::llm::client::summarize_for_compaction( + session.provider.clone(), + session.model.clone(), + compaction_reasoning(session), + prompt, + cancellation.clone(), + ) + .await + .map_err(|error| internal_error_with(&error.to_string()))?; + if cancellation.is_cancelled() { + return Err(internal_error_with("Compaction cancelled by user")); + } + let (compacted, stats) = compacted_messages( + &messages, + &selection, + &summary, + session, + before_tokens, + before_messages, + )?; + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .replace_session_messages(session_id, compacted) + .map_err(|_| internal_error())?; + Ok(stats) + } +} + +fn compaction_reasoning(session: &AcpSession) -> Option { + use crate::model::reasoning::ReasoningEffort; + let capability = model_reasoning_capability( + &session.config, + &session.models, + &session.provider, + &session.model, + )?; + [ + ReasoningEffort::None, + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ] + .into_iter() + .find(|effort| capability.values().contains(effort)) + .or(session.reasoning) + .filter(|effort| *effort != ReasoningEffort::None) +} + +fn compacted_messages( + messages: &[crate::session::types::Message], + selection: &crate::session::compaction::CompactionSelection, + summary: &str, + session: &AcpSession, + before_tokens: usize, + before_messages: usize, +) -> Result< + ( + Vec, + crate::session::types::CompactionStats, + ), + Error, +> { + let mut compacted = crate::session::compaction::apply_soft_compaction( + messages, + selection, + summary, + Some(session.model.clone()), + Some(session.provider.clone()), + Some(session.agent.clone()), + crate::session::types::CompactionStats { + before_tokens, + after_tokens: 0, + before_messages, + after_messages: 0, + }, + ); + let after_tokens = crate::session::compaction::total_context_tokens(&compacted); + let after_messages = crate::session::compaction::filter_messages_for_context(&compacted).len(); + let stats = crate::session::types::CompactionStats { + before_tokens, + after_tokens, + before_messages, + after_messages, + }; + if after_tokens >= before_tokens { + return Err(Error::invalid_params().data(format!( + "Compaction did not reduce context ({})", + crate::session::compaction::format_compaction_stats(stats) + ))); + } + if let Some(marker) = compacted + .iter_mut() + .rev() + .find(|message| crate::session::compaction::is_compaction_marker(message)) + { + marker.compaction_stats = Some(stats); + } + Ok((compacted, stats)) } fn acp_stop_reason(reason: Option) -> StopReason { @@ -1432,6 +1666,9 @@ fn replay_messages( cwd: &Path, ) -> Result<(), Error> { for message in messages { + if crate::session::compaction::is_compaction_display_item(message) { + continue; + } let message_id = message.id.clone(); match message.role { crate::session::types::MessageRole::User => { @@ -1646,6 +1883,30 @@ mod tests { model } + fn test_session() -> AcpSession { + AcpSession { + cwd: PathBuf::from("/tmp"), + config: crate::config::configuration::LoadedConfig { + merged_config: crate::config::configuration::MergedConfig::default(), + raw_merged: serde_json::Value::Null, + diagnostics: Default::default(), + inventory: Default::default(), + project_root: PathBuf::from("/tmp"), + cwd: PathBuf::from("/tmp"), + xdg_config_home: PathBuf::from("/tmp"), + }, + skills: crate::skill::SkillStore::load(Path::new("/tmp"), Path::new("/tmp")), + models: vec![model("example", "Example", "chat", "Chat")], + provider: "example".to_string(), + model: "chat".to_string(), + agent: "Build".to_string(), + reasoning_selection: crate::model::reasoning::ReasoningEffort::High, + reasoning: None, + context_window: None, + cancellation: None, + } + } + #[test] fn maps_crabcode_tools_to_acp_kinds() { assert_eq!(tool_kind("bash"), ToolKind::Execute); @@ -1779,6 +2040,62 @@ mod tests { assert_eq!(acp_stop_reason(None), StopReason::EndTurn); } + #[test] + fn recognizes_only_exact_compact_control_command() { + assert_eq!(compact_command("/compact").unwrap(), true); + assert_eq!(compact_command(" /compact ").unwrap(), true); + assert_eq!(compact_command("/compactness").unwrap(), false); + assert_eq!(compact_command("hello").unwrap(), false); + assert!(compact_command("/compact extra").is_err()); + } + + #[test] + fn advertises_compact_as_no_input_command() { + let session = test_session(); + let command = available_commands(&session) + .into_iter() + .find(|command| command.name == "compact") + .expect("compact command"); + assert_eq!( + command.description, + "Summarize this session to reduce context" + ); + assert!(command.input.is_none()); + } + + #[test] + fn builds_smaller_soft_compaction_for_acp() { + let session = test_session(); + let messages = vec![ + crate::session::types::Message::user("u".repeat(8_000)), + crate::session::types::Message::assistant("a".repeat(8_000)), + crate::session::types::Message::user("recent"), + ]; + let selection = crate::session::compaction::select_messages_for_compaction_with_min( + &messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + 0, + ) + .expect("compaction selection"); + let before_tokens = crate::session::compaction::total_context_tokens(&messages); + let before_messages = + crate::session::compaction::filter_messages_for_context(&messages).len(); + + let (compacted, stats) = compacted_messages( + &messages, + &selection, + "short handoff", + &session, + before_tokens, + before_messages, + ) + .expect("smaller compaction"); + + assert!(stats.after_tokens < stats.before_tokens); + assert!(crate::session::compaction::latest_compaction_stats(&compacted).is_some()); + assert_eq!(compacted[0].id, messages[0].id); + } + #[test] fn flattens_text_and_embedded_context() { let text = prompt_text(vec![ @@ -2075,28 +2392,7 @@ mod tests { #[test] fn preserves_selected_reasoning_effort_when_model_cannot_apply_it() { - let model = model("example", "Example", "chat", "Chat"); - let session = AcpSession { - cwd: PathBuf::from("/tmp"), - config: crate::config::configuration::LoadedConfig { - merged_config: crate::config::configuration::MergedConfig::default(), - raw_merged: serde_json::Value::Null, - diagnostics: Default::default(), - inventory: Default::default(), - project_root: PathBuf::from("/tmp"), - cwd: PathBuf::from("/tmp"), - xdg_config_home: PathBuf::from("/tmp"), - }, - skills: crate::skill::SkillStore::load(Path::new("/tmp"), Path::new("/tmp")), - models: vec![model], - provider: "example".to_string(), - model: "chat".to_string(), - agent: "Build".to_string(), - reasoning_selection: crate::model::reasoning::ReasoningEffort::High, - reasoning: None, - context_window: None, - cancellation: None, - }; + let session = test_session(); let option = reasoning_config_option(&session); assert_eq!(option.id.to_string(), "effort"); assert_eq!(option.name, "Effort"); From c9a234a727944cd1affe0303ed1ca5ac18a64a40 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 11:28:38 +0700 Subject: [PATCH 10/26] feat(acp): forward interactive questions --- Cargo.toml | 2 +- _docs/acp.mdx | 6 +- src/acp/server.rs | 2 + src/acp/service.rs | 368 +++++++++++++++++++++++++++++++++++++++++- src/app.rs | 1 + src/llm/mod.rs | 1 + src/tools/question.rs | 39 +++++ 7 files changed, 408 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9fbb98a6..a61e9a34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ json5 = "0.4" schemars = "1.0" anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } -agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork"] } +agent-client-protocol = { version = "=2.0.0", features = ["unstable_elicitation", "unstable_session_fork"] } clap_complete = "4.5" ignore = "0.4" copypasta = "0.10" diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 84aff406..d72393ad 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -52,7 +52,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | | Terminals | — | ACP terminal embedding is not implemented. | Add a client-terminal adapter for long-running shell sessions. | -| Questions | — | Interactive question prompts from the agent are not forwarded over ACP; the runtime skips them rather than blocking the editor. | Map Crabcode questions to ACP permission-style or dedicated question requests. | +| Questions | Agent questions are forwarded as ACP form elicitations with ordered single-select, multi-select, and custom-text answers when the editor advertises form elicitation support. | ACP elicitation is currently unstable; editors without form support receive a safe skipped response instead of blocking the turn. | Adopt the stable elicitation capability when ACP finalizes it and surface richer validation or defaults. | | Usage | Estimated context-window usage is emitted when the selected model exposes a context limit. | Provider-authoritative token and cost accounting is not complete; usage is omitted when no context limit is known. | Retain provider input, output, cache, context, and cost data for authoritative usage updates. | ## Session behavior @@ -63,8 +63,10 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP Crabcode applies the same configured permission rules in ACP as it does in the TUI. When a tool needs approval, the editor receives an ACP permission request. If the editor cannot respond or disconnects, Crabcode denies the request rather than continuing unattended. +Question forms are only sent to editors that advertise ACP form elicitation support. Declining, cancelling, disconnecting, or using an editor without that capability returns empty answers to the agent so the session can continue without waiting indefinitely. + Client-supplied MCP servers run with the same trust as project-configured MCP: stdio servers can execute local processes, and remote servers can send the headers and credentials the editor provides. Only attach MCP servers you trust for that workspace. Image attachments are decoded under a size limit and written to temporary files under the system temp directory (`…/crabcode/acp-images/`) for the model turn. Prefer cleaning those files after long ACP sessions until automatic cleanup lands. -The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). +The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). Client-side form elicitation is capability-gated during initialization before Crabcode sends question requests. diff --git a/src/acp/server.rs b/src/acp/server.rs index 5e87fde9..44c1caa9 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -30,12 +30,14 @@ pub async fn run(cwd: Option) -> Result<()> { })?; let service = crate::acp::service::AcpService::new(&workspace) .map_err(|_| anyhow::anyhow!("failed to initialize ACP session storage"))?; + let initialize_service = service.clone(); Agent .builder() .name("crabcode-acp") .on_receive_request( async move |request: InitializeRequest, responder, _connection| { + initialize_service.set_client_capabilities(request.client_capabilities.clone()); let response = InitializeResponse::new(request.protocol_version) .agent_capabilities(capabilities()) .agent_info(Implementation::new("crabcode", env!("CARGO_PKG_VERSION"))); diff --git a/src/acp/service.rs b/src/acp/service.rs index a34fff81..716cfc95 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -2,14 +2,16 @@ use crate::config::configuration::LoadedConfig; use crate::session::manager::SessionManager; use agent_client_protocol::schema::v1::{ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, ContentBlock, ContentChunk, - EmbeddedResourceResource, ListSessionsResponse, LoadSessionResponse, McpServer, + CreateElicitationRequest, ElicitationAction, ElicitationContentValue, ElicitationFormMode, + ElicitationSchema, ElicitationSessionScope, EmbeddedResourceResource, EnumOption, + ListSessionsResponse, LoadSessionResponse, McpServer, MultiSelectPropertySchema, NewSessionResponse, PermissionOption, PermissionOptionKind, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionResponse, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, SessionMode, SessionModeState, SessionNotification, SessionUpdate, - SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation, - ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, - UsageUpdate, + SetSessionConfigOptionResponse, StopReason, StringPropertySchema, ToolCall, ToolCallContent, + ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, + UnstructuredCommandInput, UsageUpdate, }; use agent_client_protocol::{Client, ConnectionTo, Error}; use base64::Engine as _; @@ -24,6 +26,188 @@ use tokio_util::sync::CancellationToken; pub struct AcpService { sessions: Arc>>, session_manager: Arc>, + client_capabilities: Arc>, +} + +struct AcpQuestionField { + selection: String, + custom: String, + labels: HashMap, + multiple: bool, +} + +struct AcpQuestionForm { + request: CreateElicitationRequest, + fields: Vec, +} + +fn skipped_question_answers(questions: &serde_json::Value) -> serde_json::Value { + let count = questions.as_array().map_or(1, Vec::len); + serde_json::Value::Array( + (0..count) + .map(|_| serde_json::Value::Array(Vec::new())) + .collect(), + ) +} + +fn question_text(question: &serde_json::Value, key: &str, fallback: &str) -> String { + question + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn acp_question_form( + session_id: &str, + tool_call_id: Option<&str>, + questions: &serde_json::Value, +) -> AcpQuestionForm { + let question_items = questions + .as_array() + .cloned() + .unwrap_or_else(|| vec![questions.clone()]); + let mut schema = ElicitationSchema::new() + .title("Agent questions") + .description("Answer any fields you want; blank fields are treated as skipped."); + let mut fields = Vec::with_capacity(question_items.len()); + + for (question_index, question) in question_items.iter().enumerate() { + let selection = format!("question_{question_index}"); + let custom = format!("question_{question_index}_custom"); + let prompt = question_text(question, "question", "Question"); + let header = question_text( + question, + "header", + &format!("Question {}", question_index + 1), + ); + let mut labels = HashMap::new(); + let options = question + .get("options") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter_map(|(option_index, option)| { + let label = option + .get("label") + .and_then(serde_json::Value::as_str) + .or_else(|| option.as_str())? + .trim(); + if label.is_empty() { + return None; + } + let value = format!("q{question_index}_option_{option_index}"); + labels.insert(value.clone(), label.to_string()); + let description = option + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let mut option = EnumOption::new(value, label); + if let Some(description) = description { + option = option.description(description); + } + Some(option) + }) + .collect::>(); + let multiple = question + .get("multiple") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + if multiple { + schema = schema.property( + selection.clone(), + MultiSelectPropertySchema::titled(options) + .title(header.clone()) + .description(prompt.clone()), + false, + ); + } else { + schema = schema.property( + selection.clone(), + StringPropertySchema::new() + .title(header.clone()) + .description(prompt.clone()) + .one_of(options), + false, + ); + } + schema = schema.property( + custom.clone(), + StringPropertySchema::new() + .title(format!("{header}: custom answer")) + .description("Optional free-form answer."), + false, + ); + fields.push(AcpQuestionField { + selection, + custom, + labels, + multiple, + }); + } + + let scope = ElicitationSessionScope::new(session_id.to_string()) + .tool_call_id(tool_call_id.map(agent_client_protocol::schema::v1::ToolCallId::new)); + let request = CreateElicitationRequest::new( + ElicitationFormMode::new(scope, schema), + "The agent needs additional input to continue.", + ); + AcpQuestionForm { request, fields } +} + +fn acp_question_answers( + fields: &[AcpQuestionField], + action: ElicitationAction, +) -> serde_json::Value { + let ElicitationAction::Accept(accepted) = action else { + return serde_json::Value::Array( + fields + .iter() + .map(|_| serde_json::Value::Array(Vec::new())) + .collect(), + ); + }; + let content = accepted.content.unwrap_or_default(); + serde_json::Value::Array( + fields + .iter() + .map(|field| { + let mut answers = Vec::new(); + match content.get(&field.selection) { + Some(ElicitationContentValue::String(value)) => { + if let Some(label) = field.labels.get(value) { + answers.push(serde_json::Value::String(label.clone())); + } + } + Some(ElicitationContentValue::StringArray(values)) => { + answers.extend(values.iter().filter_map(|value| { + field + .labels + .get(value) + .cloned() + .map(serde_json::Value::String) + })); + } + _ => {} + } + if let Some(ElicitationContentValue::String(custom)) = content.get(&field.custom) { + let custom = custom.trim(); + if !custom.is_empty() { + if !field.multiple { + answers.clear(); + } + answers.push(serde_json::Value::String(custom.to_string())); + } + } + serde_json::Value::Array(answers) + }) + .collect(), + ) } fn compact_command(prompt: &str) -> Result { @@ -299,9 +483,28 @@ impl AcpService { Ok(Self { sessions: Arc::new(AsyncMutex::new(HashMap::new())), session_manager: Arc::new(Mutex::new(session_manager)), + client_capabilities: Arc::new(Mutex::new(Default::default())), }) } + pub fn set_client_capabilities( + &self, + capabilities: agent_client_protocol::schema::v1::ClientCapabilities, + ) { + if let Ok(mut current) = self.client_capabilities.lock() { + *current = capabilities; + } + } + + fn supports_form_elicitation(&self) -> bool { + self.client_capabilities + .lock() + .ok() + .and_then(|capabilities| capabilities.elicitation.clone()) + .and_then(|elicitation| elicitation.form) + .is_some() + } + pub async fn available_commands( &self, session_id: &str, @@ -688,9 +891,10 @@ impl AcpService { } messages.push(user_message); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); let process_registry = std::sync::Arc::new(crate::tools::ProcessRegistry::new()); let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic_config( - None, + Some(sender.clone()), tool_permissions(&session), session.config.merged_config.agent_registry.clone(), cancellation.clone(), @@ -718,7 +922,6 @@ impl AcpService { let base_context_tokens = crate::session::compaction::total_context_tokens(&messages); send_usage(&connection, &session_id, &session, base_context_tokens)?; - let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); let stream_session_id = session_id.clone(); let stream_cancellation = cancellation.clone(); let stream_sender = sender.clone(); @@ -808,8 +1011,24 @@ impl AcpService { let response = request_permission(&connection, &session_id, &prompt).await; let _ = prompt.response_tx.send(response); } - crate::llm::ChunkMessage::QuestionRequest { response_tx, .. } => { - let _ = response_tx.send(serde_json::json!({"skipped": true})); + crate::llm::ChunkMessage::QuestionRequest { + tool_call_id, + questions, + response_tx, + } => { + let response = if self.supports_form_elicitation() { + request_questions( + &connection, + &session_id, + tool_call_id.as_deref(), + &questions, + &cancellation, + ) + .await + } else { + skipped_question_answers(&questions) + }; + let _ = response_tx.send(response); } crate::llm::ChunkMessage::TerminalSessionRequest(request) => { let _ = request @@ -1607,6 +1826,26 @@ async fn request_permission( } } +async fn request_questions( + connection: &ConnectionTo, + session_id: &str, + tool_call_id: Option<&str>, + questions: &serde_json::Value, + cancellation: &CancellationToken, +) -> serde_json::Value { + let form = acp_question_form(session_id, tool_call_id, questions); + let request = connection.send_request(form.request).block_task(); + tokio::pin!(request); + let response = tokio::select! { + _ = cancellation.cancelled() => return skipped_question_answers(questions), + response = &mut request => response, + }; + let Ok(response) = response else { + return skipped_question_answers(questions); + }; + acp_question_answers(&form.fields, response.action) +} + fn permission_title(prompt: &crate::tools::PermissionPrompt) -> String { prompt .command @@ -2027,6 +2266,119 @@ mod tests { assert!(permission_tool_call_id(None).starts_with("permission:")); } + #[test] + fn acp_question_form_preserves_single_multi_custom_and_scope() { + let form = acp_question_form( + "session_1", + Some("question_call_1"), + &serde_json::json!([ + { + "question": "Pick one", + "header": "Single", + "options": [ + {"label": "A", "description": "First"}, + {"label": "B", "description": "Second"} + ] + }, + { + "question": "Pick several", + "header": "Multiple", + "multiple": true, + "options": [ + {"label": "X", "description": "First"}, + {"label": "Y", "description": "Second"} + ] + } + ]), + ); + let wire = serde_json::to_value(&form.request).expect("elicitation request"); + + assert_eq!(wire["mode"], "form"); + assert_eq!(wire["sessionId"], "session_1"); + assert_eq!(wire["toolCallId"], "question_call_1"); + assert_eq!( + wire["requestedSchema"]["properties"]["question_0"]["type"], + "string" + ); + assert_eq!( + wire["requestedSchema"]["properties"]["question_0"]["oneOf"][0]["title"], + "A" + ); + assert_eq!( + wire["requestedSchema"]["properties"]["question_1"]["type"], + "array" + ); + assert_eq!( + wire["requestedSchema"]["properties"]["question_1_custom"]["type"], + "string" + ); + } + + #[test] + fn acp_question_answers_restore_labels_and_custom_text() { + let form = acp_question_form( + "session_1", + None, + &serde_json::json!([ + { + "question": "Pick one", + "options": [{"label": "A"}, {"label": "B"}] + }, + { + "question": "Pick several", + "multiple": true, + "options": [{"label": "X"}, {"label": "Y"}] + } + ]), + ); + let mut content = std::collections::BTreeMap::new(); + content.insert( + "question_0".to_string(), + ElicitationContentValue::String("q0_option_1".to_string()), + ); + content.insert( + "question_1".to_string(), + ElicitationContentValue::StringArray(vec![ + "q1_option_0".to_string(), + "q1_option_1".to_string(), + ]), + ); + content.insert( + "question_1_custom".to_string(), + ElicitationContentValue::String("Other choice".to_string()), + ); + let action = ElicitationAction::Accept( + agent_client_protocol::schema::v1::ElicitationAcceptAction::new().content(content), + ); + + assert_eq!( + acp_question_answers(&form.fields, action), + serde_json::json!([["B"], ["X", "Y", "Other choice"]]) + ); + assert_eq!( + acp_question_answers(&form.fields, ElicitationAction::Cancel), + serde_json::json!([[], []]) + ); + + let mut custom_content = std::collections::BTreeMap::new(); + custom_content.insert( + "question_0".to_string(), + ElicitationContentValue::String("q0_option_0".to_string()), + ); + custom_content.insert( + "question_0_custom".to_string(), + ElicitationContentValue::String("Custom only".to_string()), + ); + let custom_action = ElicitationAction::Accept( + agent_client_protocol::schema::v1::ElicitationAcceptAction::new() + .content(custom_content), + ); + assert_eq!( + acp_question_answers(&form.fields, custom_action), + serde_json::json!([["Custom only"], []]) + ); + } + #[test] fn maps_typed_turn_stop_reasons_to_acp() { assert_eq!( diff --git a/src/app.rs b/src/app.rs index cfd7bb4c..cf7796b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9820,6 +9820,7 @@ impl App { crate::llm::ChunkMessage::QuestionRequest { questions, response_tx, + .. } => { self.maybe_persist_streaming_snapshot_for_session(session_id, true); let _ = self.session_manager.set_session_status( diff --git a/src/llm/mod.rs b/src/llm/mod.rs index e9df1e13..137ba9e2 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -35,6 +35,7 @@ pub enum ChunkMessage { }, PermissionRequest(crate::tools::PermissionPrompt), QuestionRequest { + tool_call_id: Option, questions: serde_json::Value, response_tx: tokio::sync::oneshot::Sender, }, diff --git a/src/tools/question.rs b/src/tools/question.rs index e10e814f..3ee45494 100644 --- a/src/tools/question.rs +++ b/src/tools/question.rs @@ -385,6 +385,7 @@ impl ToolHandler for QuestionTool { sender .send(crate::llm::ChunkMessage::QuestionRequest { + tool_call_id: ctx.call_id.clone(), questions: questions.clone(), response_tx, }) @@ -589,4 +590,42 @@ mod tests { .unwrap() .contains("Do not call the question tool again")); } + + #[tokio::test] + async fn question_request_preserves_tool_call_id_and_answers() { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let tool = QuestionTool::new().with_sender(sender); + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + let ctx = ToolContext::new("session", "message", "Build", abort_rx) + .with_call_id("question_call_1"); + let task = tokio::spawn(async move { + tool.execute( + json!({ + "questions": [{ + "question": "Pick one", + "header": "Choice", + "options": [{"label": "A", "description": "First"}] + }] + }), + &ctx, + ) + .await + }); + + let Some(crate::llm::ChunkMessage::QuestionRequest { + tool_call_id, + questions, + response_tx, + }) = receiver.recv().await + else { + panic!("question request"); + }; + assert_eq!(tool_call_id.as_deref(), Some("question_call_1")); + assert_eq!(questions[0]["question"], "Pick one"); + response_tx.send(json!([["A"]])).expect("question response"); + + let result = task.await.expect("question task").expect("tool result"); + assert!(result.output.contains("\"status\":\"answered\"")); + assert_eq!(result.metadata["answers"], json!([["A"]])); + } } From 9d22be69f389ae33a249ced08c5d804aac160520 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 11:48:34 +0700 Subject: [PATCH 11/26] feat(acp): embed client terminals --- _docs/acp.mdx | 2 +- src/acp/service.rs | 173 +++++++++++++++++++++++++++++++--- src/tools/terminal_session.rs | 64 ++++++++++++- 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index d72393ad..eeec517a 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -51,7 +51,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | | Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | -| Terminals | — | ACP terminal embedding is not implemented. | Add a client-terminal adapter for long-running shell sessions. | +| Terminals | Interactive `terminal_session` and `bash` terminal calls run through the editor's ACP terminal host, embed in the originating tool call, retain bounded output for the model, and support cancellation with kill-and-release cleanup. | Requires an editor that advertises ACP terminal support; editors without it safely stop the request, and terminal input or resize is user-driven through the embedded editor terminal rather than agent-issued protocol requests. | Surface richer terminal lifecycle metadata and adopt protocol input/resize controls if ACP adds them. | | Questions | Agent questions are forwarded as ACP form elicitations with ordered single-select, multi-select, and custom-text answers when the editor advertises form elicitation support. | ACP elicitation is currently unstable; editors without form support receive a safe skipped response instead of blocking the turn. | Adopt the stable elicitation capability when ACP finalizes it and surface richer validation or defaults. | | Usage | Estimated context-window usage is emitted when the selected model exposes a context limit. | Provider-authoritative token and cost accounting is not complete; usage is omitted when no context limit is known. | Retain provider input, output, cache, context, and cost data for authoritative usage updates. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 716cfc95..f5f9d2c9 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -2,16 +2,17 @@ use crate::config::configuration::LoadedConfig; use crate::session::manager::SessionManager; use agent_client_protocol::schema::v1::{ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, ContentBlock, ContentChunk, - CreateElicitationRequest, ElicitationAction, ElicitationContentValue, ElicitationFormMode, - ElicitationSchema, ElicitationSessionScope, EmbeddedResourceResource, EnumOption, - ListSessionsResponse, LoadSessionResponse, McpServer, MultiSelectPropertySchema, - NewSessionResponse, PermissionOption, PermissionOptionKind, PromptResponse, - RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionResponse, SessionConfigOption, - SessionConfigOptionCategory, SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, - SessionMode, SessionModeState, SessionNotification, SessionUpdate, - SetSessionConfigOptionResponse, StopReason, StringPropertySchema, ToolCall, ToolCallContent, + CreateElicitationRequest, CreateTerminalRequest, ElicitationAction, ElicitationContentValue, + ElicitationFormMode, ElicitationSchema, ElicitationSessionScope, EmbeddedResourceResource, + EnumOption, KillTerminalRequest, ListSessionsResponse, LoadSessionResponse, McpServer, + MultiSelectPropertySchema, NewSessionResponse, PermissionOption, PermissionOptionKind, + PromptResponse, ReleaseTerminalRequest, RequestPermissionOutcome, RequestPermissionRequest, + ResumeSessionResponse, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, SessionMode, + SessionModeState, SessionNotification, SessionUpdate, SetSessionConfigOptionResponse, + StopReason, StringPropertySchema, Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, - UnstructuredCommandInput, UsageUpdate, + UnstructuredCommandInput, UsageUpdate, WaitForTerminalExitRequest, }; use agent_client_protocol::{Client, ConnectionTo, Error}; use base64::Engine as _; @@ -505,6 +506,13 @@ impl AcpService { .is_some() } + fn supports_terminals(&self) -> bool { + self.client_capabilities + .lock() + .ok() + .is_some_and(|capabilities| capabilities.terminal) + } + pub async fn available_commands( &self, session_id: &str, @@ -1031,9 +1039,20 @@ impl AcpService { let _ = response_tx.send(response); } crate::llm::ChunkMessage::TerminalSessionRequest(request) => { - let _ = request - .control_tx - .send(crate::tools::TerminalSessionControl::Stop); + if self.supports_terminals() { + bridge_terminal_session( + &connection, + &session_id, + &session.cwd, + request, + &cancellation, + ) + .await; + } else { + let _ = request + .control_tx + .send(crate::tools::TerminalSessionControl::Stop); + } } crate::llm::ChunkMessage::End => break, _ => {} @@ -1846,6 +1865,125 @@ async fn request_questions( acp_question_answers(&form.fields, response.action) } +async fn bridge_terminal_session( + connection: &ConnectionTo, + session_id: &str, + session_cwd: &Path, + request: crate::tools::TerminalSessionRequest, + cancellation: &CancellationToken, +) { + let start = request.start; + let control_tx = request.control_tx; + let cwd = start + .workdir + .as_deref() + .map(PathBuf::from) + .map(|path| absolute_tool_path(&path.to_string_lossy(), session_cwd)) + .unwrap_or_else(|| session_cwd.to_path_buf()); + let create = CreateTerminalRequest::new(session_id.to_string(), "bash") + .args(vec!["-c".to_string(), start.command.clone()]) + .cwd(cwd) + .output_byte_limit(crate::tools::terminal_session::MAX_TRANSCRIPT_BYTES as u64); + let terminal_id = match connection.send_request(create).block_task().await { + Ok(response) => response.terminal_id, + Err(error) => { + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalError( + format!("ACP client could not create terminal: {error}"), + )); + return; + } + }; + + let terminal_update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( + start.tool_call_id.clone(), + ToolCallUpdateFields::new().content(vec![ToolCallContent::Terminal(Terminal::new( + terminal_id.clone(), + ))]), + )); + if connection + .send_notification(SessionNotification::new( + session_id.to_string(), + terminal_update, + )) + .is_err() + { + let _ = connection + .send_request(ReleaseTerminalRequest::new( + session_id.to_string(), + terminal_id, + )) + .block_task() + .await; + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalError( + "ACP client could not embed terminal".to_string(), + )); + return; + } + + let wait = connection + .send_request(WaitForTerminalExitRequest::new( + session_id.to_string(), + terminal_id.clone(), + )) + .block_task(); + tokio::pin!(wait); + let (stopped_by_user, exit_code, wait_error) = tokio::select! { + _ = cancellation.cancelled() => { + let _ = connection + .send_request(KillTerminalRequest::new(session_id.to_string(), terminal_id.clone())) + .block_task() + .await; + (true, None, None) + } + response = &mut wait => match response { + Ok(response) => ( + false, + response.exit_status.exit_code.and_then(|code| i32::try_from(code).ok()), + None, + ), + Err(error) => (false, None, Some(error.to_string())), + } + }; + + let output = connection + .send_request(TerminalOutputRequest::new( + session_id.to_string(), + terminal_id.clone(), + )) + .block_task() + .await; + let _ = connection + .send_request(ReleaseTerminalRequest::new( + session_id.to_string(), + terminal_id, + )) + .block_task() + .await; + + match (wait_error, output) { + (None, Ok(output)) => { + let result = crate::tools::terminal_session::external_terminal_result( + &start, + &output.output, + output.truncated, + exit_code, + stopped_by_user, + ); + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalResult(result)); + } + (Some(error), _) => { + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalError( + format!("ACP terminal wait failed: {error}"), + )); + } + (None, Err(error)) => { + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalError( + format!("ACP terminal output failed: {error}"), + )); + } + } +} + fn permission_title(prompt: &crate::tools::PermissionPrompt) -> String { prompt .command @@ -2154,6 +2292,17 @@ mod tests { assert_eq!(tool_kind("unknown"), ToolKind::Other); } + #[test] + fn terminal_support_tracks_client_capability() { + let service = AcpService::new(Path::new("/tmp")).unwrap(); + assert!(!service.supports_terminals()); + + service.set_client_capabilities( + agent_client_protocol::schema::v1::ClientCapabilities::new().terminal(true), + ); + assert!(service.supports_terminals()); + } + #[test] fn builds_titles_from_tool_input() { assert_eq!( diff --git a/src/tools/terminal_session.rs b/src/tools/terminal_session.rs index 8860e214..447b48cc 100644 --- a/src/tools/terminal_session.rs +++ b/src/tools/terminal_session.rs @@ -36,9 +36,19 @@ pub struct TerminalSessionStart { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TerminalSessionControl { - Start { rows: u16, cols: u16 }, + Start { + rows: u16, + cols: u16, + }, Input(Vec), - Resize { rows: u16, cols: u16 }, + Resize { + rows: u16, + cols: u16, + }, + /// Complete the session using a terminal hosted by an external client. + ExternalResult(TerminalSessionResult), + /// Fail the session because an external terminal backend could not complete it. + ExternalError(String), Stop, } @@ -126,6 +136,28 @@ impl TranscriptState { } } +pub(crate) fn external_terminal_result( + start: &TerminalSessionStart, + output: &str, + truncated: bool, + exit_code: Option, + stopped_by_user: bool, +) -> TerminalSessionResult { + let mut transcript = TranscriptState::new(start.rows.max(1), start.cols.max(1)); + transcript.append(output.as_bytes()); + transcript.truncated |= truncated; + TerminalSessionResult { + session_id: start.session_id.clone(), + exit_code, + transcript_bytes: transcript.raw.len(), + transcript_truncated: transcript.truncated, + transcript_plain: transcript.plain_text(), + cols: transcript.cols, + rows: transcript.rows, + stopped_by_user, + } +} + /// Convert a PTY byte stream into display-safe text for chat history and model context. /// The live terminal still receives the original bytes through the VT parser. pub(crate) fn sanitize_terminal_output(raw: &[u8]) -> String { @@ -308,6 +340,10 @@ impl TerminalSessionTool { start.rows = rows.max(1); start.cols = cols.max(1); } + Some(TerminalSessionControl::ExternalResult(result)) => return Ok(result), + Some(TerminalSessionControl::ExternalError(error)) => { + return Err(ToolError::Execution(error)); + } Some(TerminalSessionControl::Stop) | None => { emit_event(&sender, &tool_call_id, TerminalSessionEvent::Stopped); return Ok(TerminalSessionResult { @@ -464,6 +500,8 @@ impl TerminalSessionTool { TerminalSessionEvent::Resized { rows, cols }, ); } + Some(TerminalSessionControl::ExternalResult(_)) => {} + Some(TerminalSessionControl::ExternalError(_)) => {} Some(TerminalSessionControl::Stop) | None => { stopped_by_user = true; if let Ok(mut guard) = child.lock() { @@ -730,6 +768,28 @@ mod tests { assert_eq!(state.cols, 120); } + #[test] + fn external_terminal_result_preserves_client_output_and_exit() { + let start = TerminalSessionStart { + session_id: "session".to_string(), + tool_call_id: "call".to_string(), + command: "echo hi".to_string(), + description: "test".to_string(), + workdir: None, + cols: 80, + rows: 24, + job_id: None, + }; + + let result = external_terminal_result(&start, "\x1b[31mhi\x1b[0m\n", true, Some(7), false); + + assert_eq!(result.session_id, "session"); + assert_eq!(result.transcript_plain, "hi\n"); + assert!(result.transcript_truncated); + assert_eq!(result.exit_code, Some(7)); + assert!(!result.stopped_by_user); + } + #[test] fn shell_command_builder_accepts_workdir() { let dir = PathBuf::from("/tmp/work"); From 1f404d7fe0de1053e49ae6d552eb0437eae88eec Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 12:28:05 +0700 Subject: [PATCH 12/26] feat(acp): retain authoritative usage --- _docs/acp.mdx | 2 +- src/acp/service.rs | 58 ++++++++++---- src/aisdk/README.md | 1 + src/aisdk/chunk.rs | 34 +++++++++ src/aisdk/providers/anthropic.rs | 106 +++++++++++++++++++------- src/aisdk/providers/compatible.rs | 51 ++++++++++--- src/aisdk/providers/openai.rs | 37 +++++++-- src/aisdk/response.rs | 13 +++- src/app.rs | 15 +++- src/llm/client.rs | 89 ++++++++++++++++++++++ src/llm/mod.rs | 2 + src/persistence/conversions.rs | 46 +++++++++++ src/persistence/history.rs | 122 +++++++++++++++++++++++++++--- src/persistence/migrations.rs | 56 ++++++++++++++ src/session/types.rs | 30 ++++++++ src/ui/components/chat.rs | 20 ++++- 16 files changed, 611 insertions(+), 71 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index eeec517a..2193323d 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -53,7 +53,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | | Terminals | Interactive `terminal_session` and `bash` terminal calls run through the editor's ACP terminal host, embed in the originating tool call, retain bounded output for the model, and support cancellation with kill-and-release cleanup. | Requires an editor that advertises ACP terminal support; editors without it safely stop the request, and terminal input or resize is user-driven through the embedded editor terminal rather than agent-issued protocol requests. | Surface richer terminal lifecycle metadata and adopt protocol input/resize controls if ACP adds them. | | Questions | Agent questions are forwarded as ACP form elicitations with ordered single-select, multi-select, and custom-text answers when the editor advertises form elicitation support. | ACP elicitation is currently unstable; editors without form support receive a safe skipped response instead of blocking the turn. | Adopt the stable elicitation capability when ACP finalizes it and surface richer validation or defaults. | -| Usage | Estimated context-window usage is emitted when the selected model exposes a context limit. | Provider-authoritative token and cost accounting is not complete; usage is omitted when no context limit is known. | Retain provider input, output, cache, context, and cost data for authoritative usage updates. | +| Usage | Provider-reported input, output, cache-read, and cache-write tokens are aggregated across multi-step turns and persisted per assistant message; model-catalog pricing produces cache-aware session cost totals that are emitted through ACP's cumulative USD cost field. Context-window occupancy continues to use Crabcode's transcript estimate. | Some providers or local models do not return usage, and locally computed cost is unavailable when the selected model has no pricing metadata; ACP does not expose the detailed token/cache breakdown in its standard usage update. | Adopt provider-reported monetary totals where available and expose detailed billing metadata if ACP standardizes it. | ## Session behavior diff --git a/src/acp/service.rs b/src/acp/service.rs index f5f9d2c9..00ff81ee 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -2,17 +2,18 @@ use crate::config::configuration::LoadedConfig; use crate::session::manager::SessionManager; use agent_client_protocol::schema::v1::{ AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, ContentBlock, ContentChunk, - CreateElicitationRequest, CreateTerminalRequest, ElicitationAction, ElicitationContentValue, - ElicitationFormMode, ElicitationSchema, ElicitationSessionScope, EmbeddedResourceResource, - EnumOption, KillTerminalRequest, ListSessionsResponse, LoadSessionResponse, McpServer, - MultiSelectPropertySchema, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptResponse, ReleaseTerminalRequest, RequestPermissionOutcome, RequestPermissionRequest, - ResumeSessionResponse, SessionConfigOption, SessionConfigOptionCategory, - SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, SessionMode, - SessionModeState, SessionNotification, SessionUpdate, SetSessionConfigOptionResponse, - StopReason, StringPropertySchema, Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, - ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, - UnstructuredCommandInput, UsageUpdate, WaitForTerminalExitRequest, + Cost as AcpCost, CreateElicitationRequest, CreateTerminalRequest, ElicitationAction, + ElicitationContentValue, ElicitationFormMode, ElicitationSchema, ElicitationSessionScope, + EmbeddedResourceResource, EnumOption, KillTerminalRequest, ListSessionsResponse, + LoadSessionResponse, McpServer, MultiSelectPropertySchema, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptResponse, ReleaseTerminalRequest, + RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionResponse, SessionConfigOption, + SessionConfigOptionCategory, SessionConfigSelectGroup, SessionConfigSelectOption, SessionInfo, + SessionMode, SessionModeState, SessionNotification, SessionUpdate, + SetSessionConfigOptionResponse, StopReason, StringPropertySchema, Terminal, + TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, + ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate, + WaitForTerminalExitRequest, }; use agent_client_protocol::{Client, ConnectionTo, Error}; use base64::Engine as _; @@ -100,6 +101,7 @@ fn acp_question_form( if label.is_empty() { return None; } + let value = format!("q{question_index}_option_{option_index}"); labels.insert(value.clone(), label.to_string()); let description = option @@ -928,7 +930,17 @@ impl AcpService { .await; messages.insert(0, crate::session::types::Message::system(system_prompt)); let base_context_tokens = crate::session::compaction::total_context_tokens(&messages); - send_usage(&connection, &session_id, &session, base_context_tokens)?; + let base_cost = messages + .iter() + .filter_map(|message| message.cost) + .sum::(); + send_usage( + &connection, + &session_id, + &session, + base_context_tokens, + (base_cost > 0.0).then_some(base_cost), + )?; let stream_session_id = session_id.clone(); let stream_cancellation = cancellation.clone(); @@ -1002,14 +1014,20 @@ impl AcpService { crate::llm::ChunkMessage::Metrics { token_count, duration_ms, + usage, + cost, } => { assistant.token_count = Some(token_count); assistant.duration_ms = Some(duration_ms); + if let Some(usage) = usage { + assistant.apply_usage(usage, cost); + } send_usage( &connection, &session_id, &session, base_context_tokens.saturating_add(token_count), + cost.map(|turn_cost| base_cost + turn_cost), )?; } crate::llm::ChunkMessage::Cancelled => cancelled = true, @@ -2026,11 +2044,15 @@ fn send_usage( session_id: &str, session: &AcpSession, used: usize, + cost: Option, ) -> Result<(), Error> { let Some(size) = session.context_window else { return Ok(()); }; - let update = SessionUpdate::UsageUpdate(UsageUpdate::new(used as u64, size as u64)); + let update = SessionUpdate::UsageUpdate( + UsageUpdate::new(used as u64, size as u64) + .cost(cost.map(|amount| AcpCost::new(amount, "USD"))), + ); connection .send_notification(SessionNotification::new(session_id.to_string(), update)) .map_err(|_| internal_error()) @@ -2292,6 +2314,16 @@ mod tests { assert_eq!(tool_kind("unknown"), ToolKind::Other); } + #[test] + fn acp_usage_update_includes_cumulative_usd_cost() { + let update = UsageUpdate::new(1_000, 200_000).cost(AcpCost::new(0.125, "USD")); + assert_eq!(update.cost.as_ref().map(|cost| cost.amount), Some(0.125)); + assert_eq!( + update.cost.as_ref().map(|cost| cost.currency.as_str()), + Some("USD") + ); + } + #[test] fn terminal_support_tracks_client_capability() { let service = AcpService::new(Path::new("/tmp")).unwrap(); diff --git a/src/aisdk/README.md b/src/aisdk/README.md index 2f9197f0..bfb10dbb 100644 --- a/src/aisdk/README.md +++ b/src/aisdk/README.md @@ -38,5 +38,6 @@ Done for packaging/host hooks: - Product-leaky debug path renamed/feature-gated - Product-flavored comments/tests scrubbed - Typed terminal stop reasons include normal completion, max tokens, refusal, hooks, and errors +- Normalized provider usage events retain input, output, cache-read, and cache-write token accounting across multi-step turns Keep app glue outside this tree (`src/tools/aisdk_bridge.rs`, `src/llm/*`). diff --git a/src/aisdk/chunk.rs b/src/aisdk/chunk.rs index c062ccd7..c1b3b7c1 100644 --- a/src/aisdk/chunk.rs +++ b/src/aisdk/chunk.rs @@ -20,6 +20,7 @@ pub enum ChunkType { end_turn: Option, reasoning_items: Vec, doom_loop_triggers: Vec, + usage: Option, }, Retry(crate::retry::RetryStatus), StreamRollback { @@ -28,6 +29,8 @@ pub enum ChunkType { }, Warning(String), Metadata(String), + /// Provider-reported token usage for one model request. + Usage(LanguageModelUsage), End { reason: Option, }, @@ -37,6 +40,36 @@ pub enum ChunkType { NotSupported(String), } +/// Normalized provider usage. `input_tokens` includes cached input; cache +/// fields describe subsets used for pricing and observability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LanguageModelUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, +} + +impl LanguageModelUsage { + pub fn is_empty(self) -> bool { + self.input_tokens == 0 + && self.output_tokens == 0 + && self.cache_read_tokens == 0 + && self.cache_write_tokens == 0 + } +} + +impl std::ops::AddAssign for LanguageModelUsage { + fn add_assign(&mut self, rhs: Self) { + self.input_tokens = self.input_tokens.saturating_add(rhs.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(rhs.output_tokens); + self.cache_read_tokens = self.cache_read_tokens.saturating_add(rhs.cache_read_tokens); + self.cache_write_tokens = self + .cache_write_tokens + .saturating_add(rhs.cache_write_tokens); + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ReasoningReplayItem { pub id: Option, @@ -58,6 +91,7 @@ impl ChunkType { end_turn, reasoning_items: Vec::new(), doom_loop_triggers: Vec::new(), + usage: None, } } } diff --git a/src/aisdk/providers/anthropic.rs b/src/aisdk/providers/anthropic.rs index 6eb5f53a..be1689cc 100644 --- a/src/aisdk/providers/anthropic.rs +++ b/src/aisdk/providers/anthropic.rs @@ -20,12 +20,47 @@ pub struct Anthropic { reasoning_effort: Option, } +fn anthropic_input_usage(usage: &serde_json::Value) -> Option { + let mut normalized = anthropic_usage(usage)?; + normalized.output_tokens = 0; + Some(normalized) +} + +fn anthropic_output_usage(usage: &serde_json::Value) -> Option { + let output_tokens = usage + .get("output_tokens") + .and_then(|value| value.as_u64())?; + Some(crate::chunk::LanguageModelUsage { + output_tokens, + ..Default::default() + }) +} + impl Anthropic { pub fn builder() -> AnthropicBuilder { AnthropicBuilder::default() } } +fn anthropic_stream_chunks(event_type: &str, value: &serde_json::Value) -> Vec> { + let usage = match event_type { + "message_start" => value + .get("message") + .and_then(|message| message.get("usage")) + .and_then(anthropic_input_usage), + "message_delta" => value.get("usage").and_then(anthropic_output_usage), + _ => None, + }; + let mut chunks = Vec::new(); + if let Some(usage) = usage { + chunks.push(Ok(ChunkType::Usage(usage))); + } + if let Some(chunk) = anthropic_stream_chunk(event_type, value) { + chunks.push(chunk); + } + chunks +} + #[derive(Default)] pub struct AnthropicBuilder { base_url: Option, @@ -199,29 +234,25 @@ impl Provider for Anthropic { let stream = response .bytes_stream() .eventsource() - .filter_map(|ev| match ev { + .map(|ev| match ev { Ok(event) => { let event_type = event.event.as_str(); let data = &event.data; if data.is_empty() { - return futures::future::ready(None); + return Vec::new(); } match serde_json::from_str::(data) { - Ok(value) => { - futures::future::ready(anthropic_stream_chunk(event_type, &value)) - } - Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( - "Invalid SSE data: {}", - e - ))))), + Ok(value) => anthropic_stream_chunks(event_type, &value), + Err(e) => vec![Ok(ChunkType::Failed(format!("Invalid SSE data: {}", e)))], } } - Err(e) => futures::future::ready(Some(Ok(ChunkType::RetryableFailure( - RetryError::from_message(format!("SSE error: {}", e)), - )))), + Err(e) => vec![Ok(ChunkType::RetryableFailure(RetryError::from_message( + format!("SSE error: {}", e), + )))], }) + .flat_map(futures::stream::iter) .boxed(); Ok(stream) @@ -233,13 +264,7 @@ fn anthropic_stream_chunk( value: &serde_json::Value, ) -> Option> { match event_type { - "message_start" => { - // Partial usage early in the stream (cache fields may already appear). - if let Some(usage) = value.get("message").and_then(|m| m.get("usage")) { - log_anthropic_usage(usage); - } - None - } + "message_start" => None, "content_block_start" => { if let Some(payload) = anthropic_hosted_search_start(value) { Some(Ok(ChunkType::ProviderToolCall(payload))) @@ -252,13 +277,7 @@ fn anthropic_stream_chunk( } } "content_block_delta" => anthropic_content_block_delta(value).map(Ok), - "message_delta" => { - // Final usage wins for cache_read / cache_creation. - if let Some(usage) = value.get("usage") { - log_anthropic_usage(usage); - } - anthropic_message_delta(value).map(Ok) - } + "message_delta" => anthropic_message_delta(value).map(Ok), "message_stop" => Some(Ok(ChunkType::End { reason: None })), "error" => { let error_msg = value["error"]["message"] @@ -272,7 +291,7 @@ fn anthropic_stream_chunk( /// Log Anthropic usage via the host logger so cache hits are verifiable. /// Note: `input_tokens` is non-cached only; total input ≈ input + cache_read + cache_creation. -fn log_anthropic_usage(usage: &serde_json::Value) { +fn anthropic_usage(usage: &serde_json::Value) -> Option { let input = usage.get("input_tokens").and_then(|v| v.as_u64()); let output = usage.get("output_tokens").and_then(|v| v.as_u64()); let cache_read = usage @@ -286,7 +305,7 @@ fn log_anthropic_usage(usage: &serde_json::Value) { // Skip empty/partial early frames with no signal. if input.is_none() && output.is_none() && cache_read == 0 && cache_creation == 0 { - return; + return None; } let input_v = input.unwrap_or(0); @@ -308,6 +327,12 @@ fn log_anthropic_usage(usage: &serde_json::Value) { total_input, hit_pct )); + Some(crate::chunk::LanguageModelUsage { + input_tokens: total_input, + output_tokens: output.unwrap_or(0), + cache_read_tokens: cache_read, + cache_write_tokens: cache_creation, + }) } fn anthropic_content_block_delta(value: &serde_json::Value) -> Option { @@ -825,6 +850,31 @@ mod tests { )); } + #[test] + fn message_delta_emits_usage_and_terminal_reason() { + let chunks = anthropic_stream_chunks( + "message_delta", + &serde_json::json!({ + "usage": { "output_tokens": 40 }, + "delta": { "stop_reason": "end_turn" } + }), + ); + + assert!(matches!( + chunks.first(), + Some(Ok(ChunkType::Usage(crate::chunk::LanguageModelUsage { + output_tokens: 40, + .. + }))) + )); + assert!(matches!( + chunks.get(1), + Some(Ok(ChunkType::End { + reason: Some(FinishReason::EndTurn) + })) + )); + } + #[test] fn refusal_stop_reason_emits_terminal_reason() { let value = serde_json::json!({ diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index c3473c45..a34780c8 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -436,7 +436,7 @@ fn debug_log(msg: &str) { /// Log OpenAI-compatible / AI Gateway usage via the host logger. /// Looks for `prompt_tokens_details.cached_tokens` and Anthropic-style fields /// that some gateways forward. -fn log_openai_compatible_usage(usage: &serde_json::Value) { +fn openai_compatible_usage(usage: &serde_json::Value) -> Option { let prompt = usage.get("prompt_tokens").and_then(|v| v.as_u64()); let completion = usage.get("completion_tokens").and_then(|v| v.as_u64()); let cached = usage @@ -459,7 +459,7 @@ fn log_openai_compatible_usage(usage: &serde_json::Value) { && cache_read == 0 && cache_creation == 0 { - return; + return None; } // Prefer OpenAI-style cached_tokens; fall back to Anthropic-style cache_read. @@ -489,10 +489,27 @@ fn log_openai_compatible_usage(usage: &serde_json::Value) { cache_creation, hit_pct )); + + let anthropic_shape = cached == 0 && (cache_read > 0 || cache_creation > 0); + let input_tokens = if anthropic_shape { + prompt + .unwrap_or(0) + .saturating_add(cache_read) + .saturating_add(cache_creation) + } else { + prompt.unwrap_or(0) + }; + Some(crate::chunk::LanguageModelUsage { + input_tokens, + output_tokens: completion.unwrap_or(0), + cache_read_tokens: effective_cached, + cache_write_tokens: cache_creation, + }) } fn process_sse_data(data: &str) -> Vec> { let data = data.trim(); + let mut chunks = Vec::new(); if data == "[DONE]" { debug_log("[SSE] Terminal: [DONE]"); @@ -501,7 +518,7 @@ fn process_sse_data(data: &str) -> Vec> { if data.is_empty() || is_sse_metadata_line(data) { debug_log("[SSE] Ignored: empty or metadata/comment"); - return vec![]; + return chunks; } debug_log(&format!("[SSE] Raw data: {}", data)); @@ -525,8 +542,9 @@ fn process_sse_data(data: &str) -> Vec> { // Final usage often arrives on a choices-empty (or choices-missing) chunk. // Log cache-related fields so gateway Anthropic hits are verifiable. - if let Some(usage) = value.get("usage") { - log_openai_compatible_usage(usage); + let usage = value.get("usage").and_then(openai_compatible_usage); + if let Some(usage) = usage { + chunks.push(Ok(ChunkType::Usage(usage))); } let Some(choices) = value["choices"].as_array() else { @@ -534,18 +552,16 @@ fn process_sse_data(data: &str) -> Vec> { "[SSE] No choices array. JSON keys: {:?}", value.as_object().map(|o| o.keys().collect::>()) )); - return vec![]; + return chunks; }; if choices.is_empty() { debug_log("[SSE] choices array is empty"); - return vec![]; + return chunks; } let choice = &choices[0]; let finish_reason = choice["finish_reason"].as_str().unwrap_or(""); - let mut chunks = Vec::new(); - // Log the full choice structure for debugging debug_log(&format!( "[SSE] Choice JSON: {}", @@ -645,6 +661,23 @@ mod tests { assert!(provider.api_key.is_empty()); } + #[test] + fn usage_only_chunk_emits_normalized_usage() { + let chunks = process_sse_data( + r#"{"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":80}}}"#, + ); + + assert!(matches!( + chunks.as_slice(), + [Ok(ChunkType::Usage(crate::chunk::LanguageModelUsage { + input_tokens: 120, + output_tokens: 30, + cache_read_tokens: 80, + cache_write_tokens: 0, + }))] + )); + } + #[test] fn emits_tool_call_delta_without_finish_reason() { let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"tool-1","index":0,"type":"function","function":{"name":"question","arguments":"{\"questions\":[{\"header\":\"Hobbies\",\"options\":[]}]}"}}]}}]}"#; diff --git a/src/aisdk/providers/openai.rs b/src/aisdk/providers/openai.rs index b71475a9..a119e88c 100644 --- a/src/aisdk/providers/openai.rs +++ b/src/aisdk/providers/openai.rs @@ -1562,14 +1562,13 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { return Some(Ok(responses_error_chunk(&value, event_type))); } let resp = &value["response"]; - if let Some(usage) = resp.get("usage") { - log_openai_responses_usage(usage); - } + let usage = resp.get("usage").and_then(openai_responses_usage); log_openai_responses_completed(resp); Some(Ok(ChunkType::ResponseCompleted { end_turn: resp.get("end_turn").and_then(|value| value.as_bool()), reasoning_items: reasoning_items_from_response_output(resp), doom_loop_triggers: doom_loop_triggers_from(resp), + usage, })) } // Grok Build / cli-chat-proxy: `response.doom_loop_check` with @@ -1604,7 +1603,7 @@ fn response_sse_data_to_chunk(data: &str) -> Option> { /// Log Responses API usage for prompt-cache visibility. /// Looks for `input_tokens_details.cached_tokens` (OpenAI/xAI shape). -fn log_openai_responses_usage(usage: &serde_json::Value) { +fn openai_responses_usage(usage: &serde_json::Value) -> Option { let input = usage .get("input_tokens") .or_else(|| usage.get("prompt_tokens")) @@ -1621,7 +1620,7 @@ fn log_openai_responses_usage(usage: &serde_json::Value) { .unwrap_or(0); if input.is_none() && output.is_none() && cached == 0 { - return; + return None; } let input_v = input.unwrap_or(0); @@ -1638,6 +1637,12 @@ fn log_openai_responses_usage(usage: &serde_json::Value) { cached, hit_pct )); + Some(crate::chunk::LanguageModelUsage { + input_tokens: input.unwrap_or(0), + output_tokens: output.unwrap_or(0), + cache_read_tokens: cached, + cache_write_tokens: 0, + }) } /// Attribute a `response.completed` payload: status, incomplete reason, and @@ -2508,6 +2513,28 @@ mod tests { )); } + #[test] + fn response_completed_retains_provider_usage() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.completed","response":{"usage":{"input_tokens":200,"output_tokens":50,"input_tokens_details":{"cached_tokens":150}}}}"#, + ) + .expect("expected completion chunk") + .expect("completion should parse"); + + let ChunkType::ResponseCompleted { usage, .. } = chunk else { + panic!("expected response completed"); + }; + assert_eq!( + usage, + Some(crate::chunk::LanguageModelUsage { + input_tokens: 200, + output_tokens: 50, + cache_read_tokens: 150, + cache_write_tokens: 0, + }) + ); + } + #[test] fn response_incomplete_max_output_tokens_emits_terminal_reason() { let chunk = response_sse_data_to_chunk( diff --git a/src/aisdk/response.rs b/src/aisdk/response.rs index 6377ddc5..0f46376c 100644 --- a/src/aisdk/response.rs +++ b/src/aisdk/response.rs @@ -148,6 +148,7 @@ pub async fn stream_with_tools( let mut cached_repeatable_tool_results: HashMap = HashMap::new(); let mut phase_less_ambiguous_follow_ups = 0usize; let mut doom_loop = DoomLoopTracker::default(); + let mut total_usage = crate::chunk::LanguageModelUsage::default(); loop { step_idx += 1; @@ -275,7 +276,12 @@ pub async fn stream_with_tools( end_turn, reasoning_items, doom_loop_triggers, + usage, }) => { + if let Some(usage) = usage { + total_usage += usage; + let _ = tx_loop.send(ChunkType::Usage(total_usage)); + } saw_terminal_event = true; response_end_turn = end_turn; for item in reasoning_items { @@ -357,6 +363,10 @@ pub async fn stream_with_tools( } let _ = tx_loop.send(ChunkType::Metadata(msg)); } + Ok(ChunkType::Usage(usage)) => { + total_usage += usage; + let _ = tx_loop.send(ChunkType::Usage(total_usage)); + } Ok(ChunkType::Warning(msg)) => { let _ = tx_loop.send(ChunkType::Warning(msg)); } @@ -2956,6 +2966,7 @@ mod tests { end_turn: None, reasoning_items: Vec::new(), doom_loop_triggers: vec!["tail_repetition:8@thinking".to_string()], + usage: None, }), ], 1 => vec![ @@ -4100,7 +4111,7 @@ mod tests { assert!(!empty_logged); assert_eq!(retries, 0); assert_eq!(provider.requests.load(Ordering::SeqCst), 1); - assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + assert_eq!(response.stop_reason().await, Some(StopReason::Refusal)); } #[tokio::test] diff --git a/src/app.rs b/src/app.rs index cf7796b0..78d76098 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9740,7 +9740,20 @@ impl App { self.cancelled_streaming_session(session_id); false } - crate::llm::ChunkMessage::Metrics { .. } => true, + crate::llm::ChunkMessage::Metrics { + duration_ms, + usage, + cost, + .. + } => { + if let Some(usage) = usage { + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.apply_streaming_usage(usage, cost, duration_ms); + } + self.mark_streaming_snapshot_pending(session_id); + } + true + } crate::llm::ChunkMessage::TurnStopReason(_) => true, crate::llm::ChunkMessage::ToolCalls(tool_calls) => { self.set_session_retry_status(session_id, None); diff --git a/src/llm/client.rs b/src/llm/client.rs index f0ef3106..6e21cfa7 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -43,11 +43,32 @@ struct ProviderRequestConfig { api_key: Option, reasoning_effort: Option, supports_image_input: bool, + pricing: Option, openai_options: OpenAIRequestOptions, /// Vercel AI Gateway: enable `providerOptions.gateway.caching = "auto"`. gateway_caching_auto: bool, } +fn usage_cost( + usage: crate::aisdk::chunk::LanguageModelUsage, + pricing: Option<&crate::model::discovery::Cost>, +) -> Option { + let pricing = pricing?; + let cached = usage.cache_read_tokens.min(usage.input_tokens); + let written = usage + .cache_write_tokens + .min(usage.input_tokens.saturating_sub(cached)); + let uncached = usage + .input_tokens + .saturating_sub(cached) + .saturating_sub(written); + let input_cost = uncached as f64 * pricing.input; + let cache_read_cost = cached as f64 * pricing.cache_read.unwrap_or(pricing.input); + let cache_write_cost = written as f64 * pricing.cache_write.unwrap_or(pricing.input); + let output_cost = usage.output_tokens as f64 * pricing.output; + Some((input_cost + cache_read_cost + cache_write_cost + output_cost) / 1_000_000.0) +} + fn turn_stop_reason(stop_reason: Option<&StopReason>) -> Option { match stop_reason { Some(StopReason::MaxTokens) => Some(crate::llm::TurnStopReason::MaxTokens), @@ -74,6 +95,7 @@ impl ProviderRequestConfig { api_key, reasoning_effort, supports_image_input, + pricing: None, openai_options: OpenAIRequestOptions::default(), gateway_caching_auto: false, } @@ -577,6 +599,7 @@ fn truncate_log_value(value: &str, max_chars: usize) -> String { struct StreamRelayResult { outcome: StreamRelayOutcome, stats: RelayStats, + usage: Option, } pub async fn stream_llm_with_cancellation( @@ -745,6 +768,7 @@ pub async fn stream_llm_with_cancellation( let start_time = Instant::now(); let mut token_count: usize = 0; + let pricing = request_config.pricing.clone(); let relay_result = match relay_stream_to_sender( &mut response.stream, @@ -754,6 +778,8 @@ pub async fn stream_llm_with_cancellation( &start_time, primary_log_context, model_mismatch_warning, + pricing.as_ref(), + None, ) .await .map_err(|err| err.to_string()) @@ -846,6 +872,8 @@ pub async fn stream_llm_with_cancellation( &start_time, summary_log_context, None, + pricing.as_ref(), + relay_result.usage, ) .await .map_err(|err| err.to_string()) @@ -981,6 +1009,7 @@ pub async fn summarize_for_compaction( | ChunkType::RetryableFailure(_) | ChunkType::Warning(_) | ChunkType::Metadata(_) + | ChunkType::Usage(_) | ChunkType::Start | ChunkType::Incomplete(_) => {} ChunkType::StreamRollback { text, .. } => { @@ -1040,6 +1069,7 @@ pub async fn generate_session_title( | ChunkType::RetryableFailure(_) | ChunkType::Warning(_) | ChunkType::Metadata(_) + | ChunkType::Usage(_) | ChunkType::Start | ChunkType::Incomplete(_) => {} ChunkType::StreamRollback { text, .. } => { @@ -1127,6 +1157,10 @@ async fn prepare_request_config( reasoning_effort, supports_image_input, ); + request_config.pricing = provider + .models + .get(&model_route.model_name) + .and_then(|model| model.cost.clone()); // Anthropic via AI Gateway needs explicit cache markers; gateway "auto" // inserts them. Without this, Anthropic traffic never cache-reads. if is_vercel_ai_gateway(provider_name, &model_route.npm_package) { @@ -1847,8 +1881,11 @@ async fn relay_stream_to_sender( start_time: &Instant, context: StreamLogContext<'_>, mut mismatch_warning: Option, + pricing: Option<&crate::model::discovery::Cost>, + base_usage: Option, ) -> Result { let mut stats = RelayStats::default(); + let mut stream_usage = None; crate::emit_log!( "[RELAY] relay_stream_to_sender started {}", context.describe() @@ -1951,11 +1988,15 @@ async fn relay_stream_to_sender( let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, duration_ms, + usage: combined_usage(base_usage, stream_usage), + cost: combined_usage(base_usage, stream_usage) + .and_then(|usage| usage_cost(usage, pricing)), }); let _ = sender.send(crate::llm::ChunkMessage::End); return Ok(StreamRelayResult { outcome: StreamRelayOutcome::Ended, stats, + usage: combined_usage(base_usage, stream_usage), }); } ChunkType::ResponseCompleted { end_turn, .. } => { @@ -1970,11 +2011,15 @@ async fn relay_stream_to_sender( let _ = sender.send(crate::llm::ChunkMessage::Metrics { token_count: *token_count, duration_ms, + usage: combined_usage(base_usage, stream_usage), + cost: combined_usage(base_usage, stream_usage) + .and_then(|usage| usage_cost(usage, pricing)), }); let _ = sender.send(crate::llm::ChunkMessage::End); return Ok(StreamRelayResult { outcome: StreamRelayOutcome::Ended, stats, + usage: combined_usage(base_usage, stream_usage), }); } ChunkType::AssistantMessagePhase { phase } => { @@ -1989,6 +2034,16 @@ async fn relay_stream_to_sender( stats.record_metadata(&message); crate::emit_log!("[RELAY] Metadata {}", message); } + ChunkType::Usage(usage) => { + stream_usage = Some(usage); + crate::emit_log!( + "[RELAY] Usage input={} output={} cache_read={} cache_write={}", + usage.input_tokens, + usage.output_tokens, + usage.cache_read_tokens, + usage.cache_write_tokens, + ); + } ChunkType::Retry(status) => { let elapsed_ms = start_time.elapsed().as_millis(); stats.record_chunk("Retry", elapsed_ms); @@ -2082,9 +2137,24 @@ async fn relay_stream_to_sender( Ok(StreamRelayResult { outcome: StreamRelayOutcome::Exhausted, stats, + usage: combined_usage(base_usage, stream_usage), }) } +fn combined_usage( + base: Option, + current: Option, +) -> Option { + match (base, current) { + (None, None) => None, + (Some(usage), None) | (None, Some(usage)) => Some(usage), + (Some(mut base), Some(current)) => { + base += current; + Some(base) + } + } +} + async fn reached_step_limit(agent_max_steps: Option, response: &StreamTextResponse) -> bool { agent_max_steps.is_some() && matches!(response.stop_reason().await, Some(StopReason::Hook)) } @@ -3786,3 +3856,22 @@ fn maps_runtime_stop_reasons_to_turn_events() { ); assert_eq!(turn_stop_reason(Some(&StopReason::Finish)), None); } + +#[test] +fn computes_cache_aware_usage_cost() { + let usage = crate::aisdk::chunk::LanguageModelUsage { + input_tokens: 1_000_000, + output_tokens: 100_000, + cache_read_tokens: 600_000, + cache_write_tokens: 100_000, + }; + let pricing = crate::model::discovery::Cost { + input: 2.0, + output: 10.0, + cache_read: Some(0.2), + cache_write: Some(2.5), + }; + + let cost = usage_cost(usage, Some(&pricing)).unwrap(); + assert!((cost - 1.97).abs() < f64::EPSILON); +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 137ba9e2..f9dec843 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -55,6 +55,8 @@ pub enum ChunkMessage { Metrics { token_count: usize, duration_ms: u64, + usage: Option, + cost: Option, }, } diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 8615536d..d4788adb 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -94,6 +94,11 @@ impl From for Message { t1_ms: msg.t1_ms.map(|v| v as i64), tn_ms: msg.tn_ms.map(|v| v as i64), output_tokens: msg.output_tokens.map(|v| v as i64), + input_tokens: msg.input_tokens.map(|v| v as i64), + cache_read_tokens: msg.cache_read_tokens.map(|v| v as i64), + cache_write_tokens: msg.cache_write_tokens.map(|v| v as i64), + cost: msg.cost, + usage_authoritative: msg.usage_authoritative, } } } @@ -201,6 +206,25 @@ impl TryFrom for SessionMessage { output_tokens: msg .output_tokens .and_then(|v| if v > 0 { Some(v as usize) } else { None }), + input_tokens: msg + .input_tokens + .and_then(|v| if v > 0 { Some(v as usize) } else { None }), + cache_read_tokens: msg.cache_read_tokens.and_then(|v| { + if v > 0 { + Some(v as usize) + } else { + None + } + }), + cache_write_tokens: msg.cache_write_tokens.and_then(|v| { + if v > 0 { + Some(v as usize) + } else { + None + } + }), + cost: msg.cost, + usage_authoritative: msg.usage_authoritative, tokens_per_sec: None, model: msg.model.clone(), provider: msg.provider.clone(), @@ -244,6 +268,28 @@ mod tests { assert_eq!(restored.id, id); } + #[test] + fn authoritative_usage_round_trips_through_persistence() { + let mut session_message = SessionMessage::assistant("hello"); + session_message.apply_usage( + crate::aisdk::chunk::LanguageModelUsage { + input_tokens: 100, + output_tokens: 25, + cache_read_tokens: 60, + cache_write_tokens: 10, + }, + Some(0.0125), + ); + + let restored = SessionMessage::try_from(Message::from(session_message)).unwrap(); + assert_eq!(restored.input_tokens, Some(100)); + assert_eq!(restored.output_tokens, Some(25)); + assert_eq!(restored.cache_read_tokens, Some(60)); + assert_eq!(restored.cache_write_tokens, Some(10)); + assert_eq!(restored.cost, Some(0.0125)); + assert!(restored.usage_authoritative); + } + #[test] fn compaction_stats_round_trip_through_message_parts() { let stats = CompactionStats { diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 4ac270ae..a631b599 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -15,6 +15,72 @@ pub struct Workspace { pub last_opened_at: i64, } +#[cfg(test)] +mod tests { + use super::*; + + fn test_dao() -> HistoryDAO { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations(&mut conn).unwrap(); + let workspace_id = ensure_workspace(&conn, "/tmp/workspace", "workspace").unwrap(); + HistoryDAO { + conn, + current_workspace_id: workspace_id, + current_workspace_path: "/tmp/workspace".to_string(), + current_workspace_name: "workspace".to_string(), + } + } + + #[test] + fn authoritative_usage_updates_message_and_session_totals() { + let dao = test_dao(); + let session_id = dao + .create_session("session", "Session".to_string()) + .unwrap(); + let message = Message { + id: "message".to_string(), + session_id, + role: "assistant".to_string(), + parts: Vec::new(), + timestamp: chrono::Utc::now().timestamp(), + tokens_used: 5, + model: Some("model".to_string()), + provider: Some("provider".to_string()), + agent_mode: None, + duration_ms: 10, + t0_ms: None, + t1_ms: None, + tn_ms: None, + output_tokens: Some(25), + input_tokens: Some(100), + cache_read_tokens: Some(60), + cache_write_tokens: Some(10), + cost: Some(0.0125), + usage_authoritative: true, + }; + + dao.add_message(&message).unwrap(); + let restored = dao.get_messages(session_id).unwrap(); + assert_eq!(restored[0].input_tokens, Some(100)); + assert_eq!(restored[0].cost, Some(0.0125)); + let session = dao.get_session(session_id).unwrap().unwrap(); + assert_eq!(session.total_tokens, 125); + assert!((session.total_cost - 0.0125).abs() < f64::EPSILON); + } +} + +fn message_total_tokens(message: &Message) -> i32 { + if message.usage_authoritative { + let total = message + .input_tokens + .unwrap_or(0) + .saturating_add(message.output_tokens.unwrap_or(0)); + i32::try_from(total).unwrap_or(i32::MAX) + } else { + message.tokens_used + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: i64, @@ -62,6 +128,11 @@ pub struct Message { pub t1_ms: Option, pub tn_ms: Option, pub output_tokens: Option, + pub input_tokens: Option, + pub cache_read_tokens: Option, + pub cache_write_tokens: Option, + pub cost: Option, + pub usage_authoritative: bool, } pub struct HistoryDAO { @@ -438,9 +509,10 @@ impl HistoryDAO { self.conn.execute( "INSERT INTO messages ( id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, - t0_ms, t1_ms, tn_ms, output_tokens + t0_ms, t1_ms, tn_ms, output_tokens, input_tokens, cache_read_tokens, + cache_write_tokens, cost, usage_authoritative ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", params![ &msg.id, msg.session_id, @@ -456,10 +528,21 @@ impl HistoryDAO { msg.t1_ms, msg.tn_ms, msg.output_tokens, + msg.input_tokens, + msg.cache_read_tokens, + msg.cache_write_tokens, + msg.cost, + msg.usage_authoritative, ], )?; - self.update_session_stats(msg.session_id, msg.tokens_used, 0.0, msg.timestamp)?; + let tokens = message_total_tokens(msg); + self.update_session_stats( + msg.session_id, + tokens, + msg.cost.unwrap_or(0.0), + msg.timestamp, + )?; Ok(()) } @@ -476,20 +559,23 @@ impl HistoryDAO { )?; let mut total_tokens: i64 = 0; + let mut total_cost = 0.0; let mut updated_at = chrono::Utc::now().timestamp(); { let mut insert = tx.prepare_cached( "INSERT INTO messages ( id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, - t0_ms, t1_ms, tn_ms, output_tokens + t0_ms, t1_ms, tn_ms, output_tokens, input_tokens, cache_read_tokens, + cache_write_tokens, cost, usage_authoritative ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", )?; for msg in messages { let parts_json = serde_json::to_string(&msg.parts)?; - total_tokens += msg.tokens_used as i64; + total_tokens += i64::from(message_total_tokens(msg)); + total_cost += msg.cost.unwrap_or(0.0); updated_at = msg.timestamp; insert.execute(params![ @@ -507,6 +593,11 @@ impl HistoryDAO { msg.t1_ms, msg.tn_ms, msg.output_tokens, + msg.input_tokens, + msg.cache_read_tokens, + msg.cache_write_tokens, + msg.cost, + msg.usage_authoritative, ])?; } } @@ -525,13 +616,14 @@ impl HistoryDAO { tx.execute( "UPDATE sessions SET total_tokens = ?1, - total_cost = 0, - total_time_sec = ?2, - avg_tokens_per_sec = ?3, - updated_at = ?4 - WHERE id = ?5", + total_cost = ?2, + total_time_sec = ?3, + avg_tokens_per_sec = ?4, + updated_at = ?5 + WHERE id = ?6", params![ total_tokens, + total_cost, total_time_sec, avg_tokens_per_sec, updated_at, @@ -547,7 +639,8 @@ impl HistoryDAO { pub fn get_messages(&self, session_id: i64) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, - t0_ms, t1_ms, tn_ms, output_tokens + t0_ms, t1_ms, tn_ms, output_tokens, input_tokens, cache_read_tokens, + cache_write_tokens, cost, usage_authoritative FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC, rowid ASC", )?; @@ -570,6 +663,11 @@ impl HistoryDAO { t1_ms: row.get(11)?, tn_ms: row.get(12)?, output_tokens: row.get(13)?, + input_tokens: row.get(14)?, + cache_read_tokens: row.get(15)?, + cache_write_tokens: row.get(16)?, + cost: row.get(17)?, + usage_authoritative: row.get(18)?, }) })?; diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index 1fedbbf3..0aa7550c 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -16,9 +16,41 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> { migrate_to_v3(db)?; } + if current_version < 4 { + migrate_to_v4(db)?; + } + Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_v4_adds_authoritative_usage_columns() { + let mut db = Connection::open_in_memory().unwrap(); + run_migrations(&mut db).unwrap(); + + let columns = db + .prepare("PRAGMA table_info(messages)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + for column in [ + "input_tokens", + "cache_read_tokens", + "cache_write_tokens", + "cost", + "usage_authoritative", + ] { + assert!(columns.iter().any(|candidate| candidate == column)); + } + } +} + fn get_current_version(db: &Connection) -> Result { match db.prepare("SELECT MAX(version) FROM migrations") { Ok(mut stmt) => { @@ -108,6 +140,30 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { Ok(()) } +fn migrate_to_v4(db: &mut Connection) -> Result<()> { + let tx = db.transaction()?; + let _ = tx.execute("ALTER TABLE messages ADD COLUMN input_tokens INTEGER", []); + let _ = tx.execute( + "ALTER TABLE messages ADD COLUMN cache_read_tokens INTEGER", + [], + ); + let _ = tx.execute( + "ALTER TABLE messages ADD COLUMN cache_write_tokens INTEGER", + [], + ); + let _ = tx.execute("ALTER TABLE messages ADD COLUMN cost REAL", []); + let _ = tx.execute( + "ALTER TABLE messages ADD COLUMN usage_authoritative INTEGER NOT NULL DEFAULT 0", + [], + ); + tx.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (4, strftime('%s', 'now'))", + params![], + )?; + tx.commit()?; + Ok(()) +} + fn migrate_to_v2(db: &mut Connection) -> Result<()> { let tx = db.transaction()?; diff --git a/src/session/types.rs b/src/session/types.rs index 980846b3..85648bdd 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -175,6 +175,11 @@ pub struct Message { pub t1_ms: Option, pub tn_ms: Option, pub output_tokens: Option, + pub input_tokens: Option, + pub cache_read_tokens: Option, + pub cache_write_tokens: Option, + pub cost: Option, + pub usage_authoritative: bool, /// Precomputed tokens/s (OpenCode inter-token aggregate). Prefer over /// recomputing `output_tokens / duration_ms`. pub tokens_per_sec: Option, @@ -186,6 +191,21 @@ pub struct Message { } impl Message { + pub fn apply_usage( + &mut self, + usage: crate::aisdk::chunk::LanguageModelUsage, + cost: Option, + ) { + self.input_tokens = Some(usize::try_from(usage.input_tokens).unwrap_or(usize::MAX)); + self.output_tokens = Some(usize::try_from(usage.output_tokens).unwrap_or(usize::MAX)); + self.cache_read_tokens = + Some(usize::try_from(usage.cache_read_tokens).unwrap_or(usize::MAX)); + self.cache_write_tokens = + Some(usize::try_from(usage.cache_write_tokens).unwrap_or(usize::MAX)); + self.cost = cost; + self.usage_authoritative = true; + } + pub fn new(role: MessageRole, content: impl Into) -> Self { let content = content.into(); let parts = if content.is_empty() { @@ -210,6 +230,11 @@ impl Message { t1_ms: None, tn_ms: None, output_tokens: None, + input_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost: None, + usage_authoritative: false, tokens_per_sec: None, model: None, provider: None, @@ -259,6 +284,11 @@ impl Message { t1_ms: None, tn_ms: None, output_tokens: None, + input_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost: None, + usage_authoritative: false, tokens_per_sec: None, model: None, provider: None, diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 49784036..7d7b61a7 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1860,6 +1860,22 @@ impl Chat { self.invalidate_cache(); } + pub fn apply_streaming_usage( + &mut self, + usage: crate::aisdk::chunk::LanguageModelUsage, + cost: Option, + duration_ms: u64, + ) { + if let Some(message) = self + .messages + .iter_mut() + .rfind(|message| message.role == MessageRole::Assistant) + { + message.apply_usage(usage, cost); + message.duration_ms = Some(duration_ms); + } + } + pub fn truncate_messages(&mut self, len: usize) { self.messages.truncate(len); self.invalidate_cache(); @@ -2593,7 +2609,9 @@ impl Chat { .rposition(|m| m.role == MessageRole::Assistant) { if let Some(msg) = self.messages.get_mut(idx) { - msg.output_tokens = Some(token_count); + if !msg.usage_authoritative { + msg.output_tokens = Some(token_count); + } msg.token_count = Some(token_count); msg.duration_ms = Some(decode_duration_ms); msg.tokens_per_sec = final_tps; From 0c25dd2b514ac99b14db736cc235f0179d41bd0d Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 12:42:03 +0700 Subject: [PATCH 13/26] feat(acp): persist prompt attachments --- _docs/acp.mdx | 4 +- src/acp/service.rs | 120 ++++++++++++++++++++---- src/app.rs | 20 +++- src/persistence/attachments.rs | 161 +++++++++++++++++++++++++++++++++ src/persistence/mod.rs | 3 +- src/session/manager.rs | 22 +++-- 6 files changed, 296 insertions(+), 34 deletions(-) create mode 100644 src/persistence/attachments.rs diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 2193323d..e4bdf83b 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -44,7 +44,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | --- | --- | --- | --- | | Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown. | stdout must remain protocol-only. | Add protocol-level subprocess integration coverage. | | Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | -| Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | +| Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. ACP images are stored in private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images require an image-capable selected model; audio prompt blocks are unsupported. Legacy sessions may still reference external or temporary image paths created by older versions. | Add verified provider audio-input transports and migrate readable legacy temporary attachments into managed storage. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | @@ -67,6 +67,6 @@ Question forms are only sent to editors that advertise ACP form elicitation supp Client-supplied MCP servers run with the same trust as project-configured MCP: stdio servers can execute local processes, and remote servers can send the headers and credentials the editor provides. Only attach MCP servers you trust for that workspace. -Image attachments are decoded under a size limit and written to temporary files under the system temp directory (`…/crabcode/acp-images/`) for the model turn. Prefer cleaning those files after long ACP sessions until automatic cleanup lands. +Image attachments are decoded under a 20 MiB limit and written to private session-managed storage under Crabcode's state directory (`…/crabcode/attachments//`). Closing an editor session keeps those files because history remains loadable; deleting the persisted session removes its managed attachment directory. Forks receive independent copies so deleting either session does not break the other. The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). Client-side form elicitation is capability-gated during initialization before Crabcode sends question requests. diff --git a/src/acp/service.rs b/src/acp/service.rs index 00ff81ee..ccc045fa 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -19,7 +19,6 @@ use agent_client_protocol::{Client, ConnectionTo, Error}; use base64::Engine as _; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as AsyncMutex; use tokio_util::sync::CancellationToken; @@ -31,6 +30,34 @@ pub struct AcpService { client_capabilities: Arc>, } +struct ManagedAttachmentGuard { + paths: Vec, + committed: bool, +} + +impl ManagedAttachmentGuard { + fn new(paths: Vec) -> Self { + Self { + paths, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for ManagedAttachmentGuard { + fn drop(&mut self) { + if !self.committed { + for path in &self.paths { + crate::persistence::attachments::remove_file(Path::new(path)); + } + } + } +} + struct AcpQuestionField { selection: String, custom: String, @@ -657,9 +684,21 @@ impl AcpService { .switch_current_workspace_path(&source.cwd.to_string_lossy()) .map_err(|_| internal_error())?; let fork_id = manager.create_session(Some(format!("{} (fork)", session_id))); - manager + let messages = + match crate::persistence::attachments::clone_messages(&messages, &fork_id) { + Ok(messages) => messages, + Err(_) => { + manager.delete_session(&fork_id); + return Err(internal_error()); + } + }; + if manager .replace_session_messages(&fork_id, messages) - .map_err(|_| internal_error())?; + .is_err() + { + manager.delete_session(&fork_id); + return Err(internal_error()); + } fork_id }; self.sessions @@ -857,7 +896,9 @@ impl AcpService { } return self.compact_session(&session_id, session, connection).await; } - let (prompt, local_image_paths) = prompt_content(prompt, supports_images, &session)?; + let (prompt, local_image_paths) = + prompt_content(prompt, supports_images, &session_id, &session)?; + let mut attachment_guard = ManagedAttachmentGuard::new(local_image_paths.clone()); let prompt = expand_slash_command(&session, &prompt).await?; if prompt.trim().is_empty() { return Err(Error::invalid_params().data("prompt must include text content")); @@ -891,6 +932,7 @@ impl AcpService { manager .add_message_to_session(&session_id, &user_message) .map_err(|_| internal_error())?; + attachment_guard.commit(); manager .set_session_status( &session_id, @@ -1511,11 +1553,10 @@ fn workspace_path(path: &Path) -> Result { Ok(path) } -static ACP_IMAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0); - fn prompt_content( parts: Vec, supports_images: bool, + session_id: &str, session: &AcpSession, ) -> Result<(String, Vec), Error> { let mut text = String::new(); @@ -1542,7 +1583,15 @@ fn prompt_content( session.provider, session.model ))); } - local_image_paths.push(write_prompt_image(&image)?); + match write_prompt_image(session_id, &image) { + Ok(path) => local_image_paths.push(path), + Err(error) => { + for path in &local_image_paths { + crate::persistence::attachments::remove_file(Path::new(path)); + } + return Err(error); + } + } } ContentBlock::Audio(_) => { return Err(Error::invalid_params().data("audio ACP prompts are not supported yet")); @@ -1583,6 +1632,7 @@ fn prompt_text(parts: Vec) -> Result { } fn write_prompt_image( + session_id: &str, image: &agent_client_protocol::schema::v1::ImageContent, ) -> Result { const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; @@ -1606,11 +1656,8 @@ fn write_prompt_image( return Err(Error::invalid_params().data("image exceeds the 20 MiB size limit")); } - let directory = std::env::temp_dir().join("crabcode").join("acp-images"); - std::fs::create_dir_all(&directory).map_err(|_| internal_error())?; - let sequence = ACP_IMAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let path = directory.join(format!("{}-{sequence}.{extension}", std::process::id())); - std::fs::write(&path, data).map_err(|_| internal_error())?; + let path = crate::persistence::attachments::write(session_id, extension, &data) + .map_err(|_| internal_error())?; Ok(path.to_string_lossy().into_owned()) } @@ -2648,25 +2695,58 @@ mod tests { } #[test] - fn writes_supported_acp_image_to_temp_file() { + fn writes_supported_acp_image_to_managed_session_storage() { + let session_id = format!("acp-image-{}", cuid2::create_id()); let image = agent_client_protocol::schema::v1::ImageContent::new("aGk=", "image/png"); - let path = write_prompt_image(&image).expect("image file"); + let path = write_prompt_image(&session_id, &image).expect("image file"); + assert!(Path::new(&path).starts_with(crate::persistence::attachments::root_dir())); assert_eq!(std::fs::read(&path).expect("image bytes"), b"hi"); - let _ = std::fs::remove_file(path); + crate::persistence::attachments::cleanup_session(&session_id).unwrap(); } #[test] - fn writes_acp_clipboard_image_data_uri_to_temp_file() { + fn writes_acp_clipboard_image_data_uri_to_managed_storage() { + let session_id = format!("acp-image-{}", cuid2::create_id()); let image = agent_client_protocol::schema::v1::ImageContent::new( "data:image/png;base64,aGk=", "application/octet-stream", ); - let path = write_prompt_image(&image).expect("image file"); + let path = write_prompt_image(&session_id, &image).expect("image file"); assert!(path.ends_with(".png")); assert_eq!(std::fs::read(&path).expect("image bytes"), b"hi"); - let _ = std::fs::remove_file(path); + crate::persistence::attachments::cleanup_session(&session_id).unwrap(); + } + + #[test] + fn prompt_image_failure_rolls_back_prior_managed_files() { + let session_id = format!("acp-image-{}", cuid2::create_id()); + let result = prompt_content( + vec![ + ContentBlock::Image(agent_client_protocol::schema::v1::ImageContent::new( + "aGk=", + "image/png", + )), + ContentBlock::Image(agent_client_protocol::schema::v1::ImageContent::new( + "not-base64", + "image/png", + )), + ], + true, + &session_id, + &test_session(), + ); + + assert!(result.is_err()); + let directory = crate::persistence::attachments::session_dir(&session_id).unwrap(); + assert!( + !directory.exists() + || std::fs::read_dir(&directory) + .unwrap() + .all(|entry| entry.is_err()) + ); + crate::persistence::attachments::cleanup_session(&session_id).unwrap(); } #[test] @@ -2676,14 +2756,14 @@ mod tests { "image/png", ); - assert!(write_prompt_image(&image).is_err()); + assert!(write_prompt_image("test", &image).is_err()); } #[test] fn rejects_unsupported_acp_image_mime_type() { let image = agent_client_protocol::schema::v1::ImageContent::new("aGk=", "image/tiff"); - assert!(write_prompt_image(&image).is_err()); + assert!(write_prompt_image("test", &image).is_err()); } fn config_with_command(command: crate::command::custom::CustomCommand) -> LoadedConfig { diff --git a/src/app.rs b/src/app.rs index 78d76098..bb2be8af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7500,9 +7500,23 @@ impl App { .map(|session| fork_title_from_session_title(&session.title)) .unwrap_or_else(|| fork_title_from_session_title("fork")); - let _ = self.create_new_session(Some(fork_title)); - for msg in &messages_to_fork { - let _ = self.session_manager.add_message_to_current_session(msg); + let fork_id = self.create_new_session(Some(fork_title)); + let messages_to_fork = + match crate::persistence::attachments::clone_messages(&messages_to_fork, &fork_id) { + Ok(messages) => messages, + Err(error) => { + self.session_manager.delete_session(&fork_id); + self.push_command_error(format!("Failed to copy fork attachments: {error}")); + return false; + } + }; + if let Err(error) = self + .session_manager + .replace_session_messages(&fork_id, messages_to_fork.clone()) + { + self.session_manager.delete_session(&fork_id); + self.push_command_error(format!("Failed to persist fork: {error:?}")); + return false; } self.chat_state.chat.clear(); diff --git a/src/persistence/attachments.rs b/src/persistence/attachments.rs new file mode 100644 index 00000000..80d054f9 --- /dev/null +++ b/src/persistence/attachments.rs @@ -0,0 +1,161 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::{Component, Path, PathBuf}; + +pub fn root_dir() -> PathBuf { + if cfg!(test) || std::env::var_os("CRABCODE_TEST_MODE").is_some() { + PathBuf::from("/tmp/crabcode_test_data/attachments") + } else { + super::get_data_dir().join("attachments") + } +} + +fn validate_session_id(session_id: &str) -> Result<()> { + let path = Path::new(session_id); + if session_id.is_empty() + || path.is_absolute() + || path.components().count() != 1 + || !matches!(path.components().next(), Some(Component::Normal(_))) + { + return Err(anyhow!("invalid attachment session id")); + } + Ok(()) +} + +pub fn session_dir(session_id: &str) -> Result { + validate_session_id(session_id)?; + Ok(root_dir().join(session_id)) +} + +pub fn ensure_session_dir(session_id: &str) -> Result { + let dir = session_dir(session_id)?; + super::create_private_dir_all(&dir)?; + Ok(dir) +} + +pub fn write(session_id: &str, extension: &str, data: &[u8]) -> Result { + if extension.is_empty() || !extension.chars().all(|ch| ch.is_ascii_alphanumeric()) { + return Err(anyhow!("invalid attachment extension")); + } + let dir = ensure_session_dir(session_id)?; + let id = cuid2::create_id(); + let final_path = dir.join(format!("{id}.{extension}")); + let temporary_path = dir.join(format!(".{id}.tmp")); + std::fs::write(&temporary_path, data) + .with_context(|| format!("failed to write attachment {}", temporary_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temporary_path, std::fs::Permissions::from_mode(0o600))?; + } + if let Err(error) = std::fs::rename(&temporary_path, &final_path) { + let _ = std::fs::remove_file(&temporary_path); + return Err(error) + .with_context(|| format!("failed to finalize attachment {}", final_path.display())); + } + Ok(final_path) +} + +pub fn is_managed(path: &Path) -> bool { + path.strip_prefix(root_dir()).is_ok_and(|relative| { + relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + }) +} + +pub fn remove_file(path: &Path) { + if is_managed(path) { + let _ = std::fs::remove_file(path); + } +} + +pub fn cleanup_session(session_id: &str) -> Result<()> { + let dir = session_dir(session_id)?; + match std::fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("failed to remove {}", dir.display())), + } +} + +pub fn clone_messages( + messages: &[crate::session::types::Message], + destination_session_id: &str, +) -> Result> { + let mut cloned = messages.to_vec(); + let mut created = Vec::new(); + for message in &mut cloned { + message.id = cuid2::create_id(); + for image_path in &mut message.local_image_paths { + let source = PathBuf::from(&*image_path); + if !is_managed(&source) { + continue; + } + if std::fs::symlink_metadata(&source)?.file_type().is_symlink() { + return Err(anyhow!("managed attachment cannot be a symlink")); + } + let extension = source + .extension() + .and_then(|extension| extension.to_str()) + .ok_or_else(|| anyhow!("managed attachment has no extension"))?; + let data = std::fs::read(&source) + .with_context(|| format!("failed to read attachment {}", source.display()))?; + match write(destination_session_id, extension, &data) { + Ok(path) => { + *image_path = path.to_string_lossy().into_owned(); + created.push(path); + } + Err(error) => { + for path in created { + remove_file(&path); + } + return Err(error); + } + } + } + } + Ok(cloned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_attachment_round_trip_and_cleanup() { + let session = format!("attachment-test-{}", cuid2::create_id()); + let path = write(&session, "png", b"png-data").unwrap(); + assert!(is_managed(&path)); + assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); + cleanup_session(&session).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn fork_clones_managed_files_and_regenerates_message_ids() { + let source_session = format!("attachment-source-{}", cuid2::create_id()); + let destination_session = format!("attachment-dest-{}", cuid2::create_id()); + let source = write(&source_session, "png", b"image").unwrap(); + let mut message = crate::session::types::Message::user("image"); + let original_id = message.id.clone(); + message.local_image_paths = vec![source.to_string_lossy().into_owned()]; + + let cloned = clone_messages(&[message], &destination_session).unwrap(); + assert_ne!(cloned[0].id, original_id); + assert_ne!(cloned[0].local_image_paths[0], source.to_string_lossy()); + assert_eq!( + std::fs::read(&cloned[0].local_image_paths[0]).unwrap(), + b"image" + ); + + cleanup_session(&source_session).unwrap(); + assert!(Path::new(&cloned[0].local_image_paths[0]).exists()); + cleanup_session(&destination_session).unwrap(); + } + + #[test] + fn traversal_path_is_not_managed() { + let traversal = root_dir().join("session").join("..").join("outside.png"); + assert!(!is_managed(&traversal)); + } +} diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 47129682..fc18bc58 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::ffi::OsString; use std::path::{Path, PathBuf}; +pub mod attachments; pub mod auth; pub mod conversions; pub mod db; @@ -55,7 +56,7 @@ fn resolve_state_home(xdg_state_home: Option, home_dir: Option Result<()> { +pub(crate) fn create_private_dir_all(dir: &Path) -> Result<()> { std::fs::create_dir_all(dir)?; restrict_dir_permissions(dir)?; Ok(()) diff --git a/src/session/manager.rs b/src/session/manager.rs index 47eb694b..54f45782 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -730,11 +730,7 @@ impl SessionManager { session_id: &str, message: &crate::session::types::Message, ) -> Result<(), SessionError> { - if let Some(session) = self.sessions.get_mut(session_id) { - session.add_message(message.clone()); - self.message_counts - .insert(session_id.to_string(), session.messages.len()); - } else { + if !self.sessions.contains_key(session_id) { return Err(SessionError::NotFound(session_id.to_string())); } @@ -742,11 +738,18 @@ impl SessionManager { if let Some(db_id) = self.id_mapping.get(session_id) { let mut db_message: crate::persistence::Message = message.clone().into(); db_message.session_id = *db_id; - let _ = dao - .add_message(&db_message) - .map_err(|e| SessionError::PersistenceError(e.to_string())); + dao.add_message(&db_message) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; } } + + let session = self + .sessions + .get_mut(session_id) + .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?; + session.add_message(message.clone()); + self.message_counts + .insert(session_id.to_string(), session.messages.len()); Ok(()) } @@ -969,6 +972,9 @@ impl SessionManager { if self.current_session_id.as_ref() == Some(&id.to_string()) { self.current_session_id = None; } + if let Err(error) = crate::persistence::attachments::cleanup_session(id) { + crate::emit_log!("Failed to clean session attachments for {}: {}", id, error); + } true } else { false From dcb56a312694f823d317a985e30e4d9ea78592f7 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 13:22:09 +0700 Subject: [PATCH 14/26] feat(acp): support audio prompts --- _docs/acp.mdx | 6 +- src/acp/server.rs | 19 +++- src/acp/service.rs | 113 +++++++++++++++++-- src/aisdk/README.md | 1 + src/aisdk/message.rs | 25 +++++ src/aisdk/providers/compatible.rs | 32 +++++- src/llm/client.rs | 174 +++++++++++++++++++++++++----- src/model/discovery.rs | 59 ++++++++++ src/persistence/attachments.rs | 49 +++++---- src/persistence/conversions.rs | 31 ++++++ src/remote/mod.rs | 4 + src/session/compaction.rs | 13 ++- src/session/types.rs | 3 + src/ui/components/chat.rs | 2 + 14 files changed, 466 insertions(+), 65 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index e4bdf83b..08be4511 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -44,7 +44,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | --- | --- | --- | --- | | Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown. | stdout must remain protocol-only. | Add protocol-level subprocess integration coverage. | | Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | -| Prompts | Text, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. ACP images are stored in private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images require an image-capable selected model; audio prompt blocks are unsupported. Legacy sessions may still reference external or temporary image paths created by older versions. | Add verified provider audio-input transports and migrate readable legacy temporary attachments into managed storage. | +| Prompts | Text, embedded text resources, PNG/JPEG/GIF/WebP images, and WAV or MP3 audio attachments; assistant text and reasoning stream back to the editor. ACP attachments use private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images and audio require matching selected-model input modalities. Audio currently uses the verified OpenAI-compatible Chat Completions `input_audio` transport; Responses-only and Anthropic transports reject it. Legacy sessions may still reference external or temporary image paths created by older versions. | Add additional verified provider audio transports and migrate readable legacy temporary attachments into managed storage. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | @@ -67,6 +67,6 @@ Question forms are only sent to editors that advertise ACP form elicitation supp Client-supplied MCP servers run with the same trust as project-configured MCP: stdio servers can execute local processes, and remote servers can send the headers and credentials the editor provides. Only attach MCP servers you trust for that workspace. -Image attachments are decoded under a 20 MiB limit and written to private session-managed storage under Crabcode's state directory (`…/crabcode/attachments//`). Closing an editor session keeps those files because history remains loadable; deleting the persisted session removes its managed attachment directory. Forks receive independent copies so deleting either session does not break the other. +Image and audio attachments are decoded under a 20 MiB-per-file limit and written to private session-managed storage under Crabcode's state directory (`…/crabcode/attachments//`). Audio input accepts WAV and MP3 only. Closing an editor session keeps those files because history remains loadable; deleting the persisted session removes its managed attachment directory. Forks receive independent copies so deleting either session does not break the other. -The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). Client-side form elicitation is capability-gated during initialization before Crabcode sends question requests. +The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image, audio, and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). Client-side form elicitation and terminal hosting are capability-gated during initialization before Crabcode sends those requests. diff --git a/src/acp/server.rs b/src/acp/server.rs index 44c1caa9..522dce37 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -242,7 +242,12 @@ pub async fn run(cwd: Option) -> Result<()> { fn capabilities() -> AgentCapabilities { AgentCapabilities::new() .load_session(true) - .prompt_capabilities(PromptCapabilities::new().embedded_context(true).image(true)) + .prompt_capabilities( + PromptCapabilities::new() + .embedded_context(true) + .image(true) + .audio(true), + ) .mcp_capabilities(McpCapabilities::new().http(true).sse(true)) .session_capabilities( SessionCapabilities::new() @@ -252,3 +257,15 @@ fn capabilities() -> AgentCapabilities { .close(SessionCloseCapabilities::new()), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_audio_prompt_support() { + let prompt = capabilities().prompt_capabilities; + assert!(prompt.audio); + assert!(prompt.image); + } +} diff --git a/src/acp/service.rs b/src/acp/service.rs index ccc045fa..6c5b5cb9 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,39 @@ pub struct AcpService { client_capabilities: Arc>, } +fn write_prompt_audio( + session_id: &str, + audio: &agent_client_protocol::schema::v1::AudioContent, +) -> Result { + const MAX_AUDIO_BYTES: usize = 20 * 1024 * 1024; + let media_type = audio.mime_type.trim().to_ascii_lowercase(); + let extension = match media_type.as_str() { + "audio/wav" | "audio/x-wav" | "audio/wave" => "wav", + "audio/mpeg" | "audio/mp3" => "mp3", + _ => { + return Err(Error::invalid_params() + .data(format!("unsupported audio MIME type: {}", audio.mime_type))); + } + }; + let data = base64::engine::general_purpose::STANDARD + .decode(audio.data.trim()) + .map_err(|error| Error::invalid_params().data(format!("invalid audio data: {error}")))?; + if data.len() > MAX_AUDIO_BYTES { + return Err(Error::invalid_params().data("audio exceeds the 20 MiB size limit")); + } + let path = crate::persistence::attachments::write(session_id, extension, &data) + .map_err(|_| internal_error())?; + Ok(path.to_string_lossy().into_owned()) +} + +fn model_supports_audio(config: &LoadedConfig, provider: &str, model: &str) -> bool { + crate::model::discovery::Discovery::new_with_custom(Some( + config.merged_config.custom_providers.clone(), + )) + .ok() + .is_some_and(|discovery| discovery.model_supports_input_modality(provider, model, "audio")) +} + struct ManagedAttachmentGuard { paths: Vec, committed: bool, @@ -896,9 +929,18 @@ impl AcpService { } return self.compact_session(&session_id, session, connection).await; } - let (prompt, local_image_paths) = - prompt_content(prompt, supports_images, &session_id, &session)?; - let mut attachment_guard = ManagedAttachmentGuard::new(local_image_paths.clone()); + let supports_audio = + model_supports_audio(&session.config, &session.provider, &session.model); + let (prompt, local_image_paths, local_audio_paths) = prompt_content( + prompt, + supports_images, + supports_audio, + &session_id, + &session, + )?; + let mut managed_paths = local_image_paths.clone(); + managed_paths.extend(local_audio_paths.clone()); + let mut attachment_guard = ManagedAttachmentGuard::new(managed_paths); let prompt = expand_slash_command(&session, &prompt).await?; if prompt.trim().is_empty() { return Err(Error::invalid_params().data("prompt must include text content")); @@ -924,6 +966,7 @@ impl AcpService { }; let mut user_message = crate::session::types::Message::user(&prompt); user_message.local_image_paths = local_image_paths; + user_message.local_audio_paths = local_audio_paths; user_message.provider = Some(session.provider.clone()); user_message.model = Some(session.model.clone()); user_message.agent_mode = Some(session.agent.clone()); @@ -1556,11 +1599,13 @@ fn workspace_path(path: &Path) -> Result { fn prompt_content( parts: Vec, supports_images: bool, + supports_audio: bool, session_id: &str, session: &AcpSession, -) -> Result<(String, Vec), Error> { +) -> Result<(String, Vec, Vec), Error> { let mut text = String::new(); let mut local_image_paths = Vec::new(); + let mut local_audio_paths = Vec::new(); for part in parts { match part { ContentBlock::Text(content) => text.push_str(&content.text), @@ -1593,16 +1638,37 @@ fn prompt_content( } } } - ContentBlock::Audio(_) => { - return Err(Error::invalid_params().data("audio ACP prompts are not supported yet")); + ContentBlock::Audio(audio) => { + if !supports_audio { + for path in local_image_paths.iter().chain(local_audio_paths.iter()) { + crate::persistence::attachments::remove_file(Path::new(path)); + } + return Err(Error::invalid_params().data(format!( + "model {}/{} does not support audio input", + session.provider, session.model + ))); + } + match write_prompt_audio(session_id, &audio) { + Ok(path) => local_audio_paths.push(path), + Err(error) => { + for path in local_image_paths.iter().chain(local_audio_paths.iter()) { + crate::persistence::attachments::remove_file(Path::new(path)); + } + return Err(error); + } + } } _ => {} } } - if text.is_empty() && !local_image_paths.is_empty() { - text.push_str("[Image attached]"); + if text.is_empty() { + if !local_image_paths.is_empty() { + text.push_str("[Image attached]"); + } else if !local_audio_paths.is_empty() { + text.push_str("[Audio attached]"); + } } - Ok((text, local_image_paths)) + Ok((text, local_image_paths, local_audio_paths)) } fn prompt_text(parts: Vec) -> Result { @@ -2705,6 +2771,34 @@ mod tests { crate::persistence::attachments::cleanup_session(&session_id).unwrap(); } + #[test] + fn writes_supported_acp_audio_to_managed_session_storage() { + let session_id = format!("acp-audio-{}", cuid2::create_id()); + let audio = agent_client_protocol::schema::v1::AudioContent::new("YXVkaW8=", "audio/wav"); + let path = write_prompt_audio(&session_id, &audio).expect("audio file"); + + assert!(path.ends_with(".wav")); + assert_eq!(std::fs::read(&path).unwrap(), b"audio"); + crate::persistence::attachments::cleanup_session(&session_id).unwrap(); + } + + #[test] + fn rejects_audio_for_models_without_audio_modality() { + let session_id = format!("acp-audio-{}", cuid2::create_id()); + let result = prompt_content( + vec![ContentBlock::Audio( + agent_client_protocol::schema::v1::AudioContent::new("YXVkaW8=", "audio/wav"), + )], + false, + false, + &session_id, + &test_session(), + ); + + assert!(result.is_err()); + crate::persistence::attachments::cleanup_session(&session_id).unwrap(); + } + #[test] fn writes_acp_clipboard_image_data_uri_to_managed_storage() { let session_id = format!("acp-image-{}", cuid2::create_id()); @@ -2734,6 +2828,7 @@ mod tests { )), ], true, + false, &session_id, &test_session(), ); diff --git a/src/aisdk/README.md b/src/aisdk/README.md index bfb10dbb..64ac4eda 100644 --- a/src/aisdk/README.md +++ b/src/aisdk/README.md @@ -39,5 +39,6 @@ Done for packaging/host hooks: - Product-flavored comments/tests scrubbed - Typed terminal stop reasons include normal completion, max tokens, refusal, hooks, and errors - Normalized provider usage events retain input, output, cache-read, and cache-write token accounting across multi-step turns +- User messages support typed image and WAV/MP3 audio inputs; audio is serialized through verified Chat Completions `input_audio` content parts Keep app glue outside this tree (`src/tools/aisdk_bridge.rs`, `src/llm/*`). diff --git a/src/aisdk/message.rs b/src/aisdk/message.rs index ffdfc222..50459f92 100644 --- a/src/aisdk/message.rs +++ b/src/aisdk/message.rs @@ -5,6 +5,13 @@ pub(crate) fn is_prefixed_response_item_id(id: &str) -> bool { .is_some_and(|(prefix, suffix)| !prefix.is_empty() && !suffix.is_empty()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioContent { + pub data: String, + pub format: String, + pub media_type: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "role")] pub enum Message { @@ -35,6 +42,7 @@ impl Message { Self::User(UserMessage { content: content.into(), images: Vec::new(), + audios: Vec::new(), }) } @@ -42,6 +50,19 @@ impl Message { Self::User(UserMessage { content: content.into(), images, + audios: Vec::new(), + }) + } + + pub fn user_with_attachments( + content: impl Into, + images: Vec, + audios: Vec, + ) -> Self { + Self::User(UserMessage { + content: content.into(), + images, + audios, }) } @@ -159,6 +180,8 @@ pub struct UserMessage { pub content: String, #[serde(default)] pub images: Vec, + #[serde(default)] + pub audios: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -231,6 +254,7 @@ impl From for UserMessage { Self { content, images: Vec::new(), + audios: Vec::new(), } } } @@ -240,6 +264,7 @@ impl From<&str> for UserMessage { Self { content: content.to_string(), images: Vec::new(), + audios: Vec::new(), } } } diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index a34780c8..dae2ce17 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -238,7 +238,7 @@ impl Provider for OpenAICompatible { } fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_json::Value { - if user.images.is_empty() { + if user.images.is_empty() && user.audios.is_empty() { return serde_json::json!(user.content); } @@ -257,6 +257,15 @@ fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_j }, }) })); + parts.extend(user.audios.iter().map(|audio| { + serde_json::json!({ + "type": "input_audio", + "input_audio": { + "data": audio.data, + "format": audio.format, + }, + }) + })); serde_json::Value::Array(parts) } @@ -661,6 +670,27 @@ mod tests { assert!(provider.api_key.is_empty()); } + #[test] + fn serializes_audio_input_content_part() { + let user = crate::message::UserMessage { + content: "Describe this".to_string(), + images: Vec::new(), + audios: vec![crate::message::AudioContent { + data: "YXVkaW8=".to_string(), + format: "wav".to_string(), + media_type: "audio/wav".to_string(), + }], + }; + + assert_eq!( + openai_compatible_user_content(&user), + serde_json::json!([ + {"type": "text", "text": "Describe this"}, + {"type": "input_audio", "input_audio": {"data": "YXVkaW8=", "format": "wav"}} + ]) + ); + } + #[test] fn usage_only_chunk_emits_normalized_usage() { let chunks = process_sse_data( diff --git a/src/llm/client.rs b/src/llm/client.rs index 6e21cfa7..9c81fd7c 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -5,10 +5,10 @@ use crate::aisdk::core::{ stop::StopReason, Message as AisdkMessage, Tool, }; -use crate::aisdk::message::ImageContent; +use crate::aisdk::message::{AudioContent, ImageContent}; use crate::aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; -use std::{collections::HashMap, time::Instant}; +use std::{collections::HashMap, path::Path, time::Instant}; use tokio_util::sync::CancellationToken; use crate::tools::aisdk_bridge::convert_to_aisdk_tools; @@ -43,12 +43,32 @@ struct ProviderRequestConfig { api_key: Option, reasoning_effort: Option, supports_image_input: bool, + supports_audio_input: bool, pricing: Option, openai_options: OpenAIRequestOptions, /// Vercel AI Gateway: enable `providerOptions.gateway.caching = "auto"`. gateway_caching_auto: bool, } +fn provider_kind_for_model( + provider_name: &str, + npm_package: &str, + supports_audio_input: bool, +) -> ProviderKind { + let kind = ProviderKind::from_provider(provider_name, npm_package); + if supports_audio_input && kind == ProviderKind::OpenAI { + ProviderKind::OpenAICompatible + } else { + kind + } +} + +fn model_supports_audio_input(model: Option<&crate::model::discovery::Model>) -> bool { + model + .and_then(|model| model.modalities.as_ref()) + .is_some_and(|modalities| modalities.input.iter().any(|item| item == "audio")) +} + fn usage_cost( usage: crate::aisdk::chunk::LanguageModelUsage, pricing: Option<&crate::model::discovery::Cost>, @@ -95,6 +115,7 @@ impl ProviderRequestConfig { api_key, reasoning_effort, supports_image_input, + supports_audio_input: false, pricing: None, openai_options: OpenAIRequestOptions::default(), gateway_caching_auto: false, @@ -703,9 +724,10 @@ pub async fn stream_llm_with_cancellation( ); } - let aisdk_messages = convert_messages_for_model( + let aisdk_messages = convert_messages_for_model_with_audio( &messages, request_config.supports_image_input, + request_config.supports_audio_input, show_vlm_agent_hint, ); // Stamp Build affinity *after* message conversion so turn_idx matches wire content. @@ -1134,8 +1156,13 @@ async fn prepare_request_config( }; let supports_image_input = model_supports_image_input(&model, provider.models.get(&model)); + let supports_audio_input = model_supports_audio_input(provider.models.get(&model)); let model_route = resolve_model_route(&provider, model); - let provider_kind = ProviderKind::from_provider(provider_name, &model_route.npm_package); + let provider_kind = provider_kind_for_model( + provider_name, + &model_route.npm_package, + supports_audio_input, + ); let base_url = if provider_name == "xai" && model_route.api.trim().is_empty() { // models.dev currently ships empty api for xAI; default to the public endpoint. "https://api.x.ai".to_string() @@ -1161,6 +1188,7 @@ async fn prepare_request_config( .models .get(&model_route.model_name) .and_then(|model| model.cost.clone()); + request_config.supports_audio_input = supports_audio_input; // Anthropic via AI Gateway needs explicit cache markers; gateway "auto" // inserts them. Without this, Anthropic traffic never cache-reads. if is_vercel_ai_gateway(provider_name, &model_route.npm_package) { @@ -2163,6 +2191,33 @@ fn estimate_tokens(content: &str) -> usize { content.chars().count().max(1) / 4 } +fn audio_content_for_path(path: &Path) -> Option { + use base64::Engine as _; + let extension = path + .extension() + .and_then(|extension| extension.to_str())? + .to_ascii_lowercase(); + let media_type = match extension.as_str() { + "wav" => "audio/wav", + "mp3" => "audio/mpeg", + _ => { + crate::emit_log!("unsupported audio attachment format: {}", path.display()); + return None; + } + }; + match std::fs::read(path) { + Ok(data) => Some(AudioContent { + data: base64::engine::general_purpose::STANDARD.encode(data), + format: extension, + media_type: media_type.to_string(), + }), + Err(error) => { + crate::emit_log!("failed to attach audio {}: {}", path.display(), error); + None + } + } +} + fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { convert_messages_for_model(messages, true, false) } @@ -2171,6 +2226,20 @@ fn convert_messages_for_model( messages: &[crate::session::types::Message], supports_image_input: bool, show_vlm_agent_hint: bool, +) -> Vec { + convert_messages_for_model_with_audio( + messages, + supports_image_input, + false, + show_vlm_agent_hint, + ) +} + +fn convert_messages_for_model_with_audio( + messages: &[crate::session::types::Message], + supports_image_input: bool, + supports_audio_input: bool, + show_vlm_agent_hint: bool, ) -> Vec { let mut aisdk_messages = Vec::new(); // Soft compaction keeps full UI history; only the active post-boundary @@ -2193,22 +2262,17 @@ fn convert_messages_for_model( aisdk_messages.push(AisdkMessage::system(content)); } crate::session::types::MessageRole::User => { - let content = crate::utils::sanitize::strip_legacy_image_descriptions(&msg.content); + let mut content = + crate::utils::sanitize::strip_legacy_image_descriptions(&msg.content); if !supports_image_input && !msg.local_image_paths.is_empty() { if show_vlm_agent_hint { - aisdk_messages.push(AisdkMessage::user(content_with_vlm_agent_hint( - &content, - &msg.local_image_paths, - ))); + content = content_with_vlm_agent_hint(&content, &msg.local_image_paths); } else { - aisdk_messages.push(AisdkMessage::user( - content_with_unsupported_image_note( - &content, - msg.local_image_paths.len(), - ), - )); + content = content_with_unsupported_image_note( + &content, + msg.local_image_paths.len(), + ); } - continue; } let images = msg @@ -2233,18 +2297,36 @@ fn convert_messages_for_model( }) .collect::>(); - // Empty user rows without images also pad the sticky prefix. - if content.trim().is_empty() && images.is_empty() { + let audios = if supports_audio_input { + msg.local_audio_paths + .iter() + .filter_map(|path| audio_content_for_path(Path::new(path))) + .collect::>() + } else { + if !msg.local_audio_paths.is_empty() { + content.push_str(&format!( + "\n\n[{} audio attachment(s) omitted because the selected model does not support audio input.]", + msg.local_audio_paths.len() + )); + } + Vec::new() + }; + + // Empty user rows without attachments also pad the sticky prefix. + if content.trim().is_empty() && images.is_empty() && audios.is_empty() { continue; } - if images.is_empty() { + if images.is_empty() && audios.is_empty() { aisdk_messages.push(AisdkMessage::user(content)); } else { - aisdk_messages.push(AisdkMessage::user_with_images( - content_with_vision_attached_image_hint(&content), - images, - )); + let content = if images.is_empty() { + content + } else { + content_with_vision_attached_image_hint(&content) + }; + aisdk_messages + .push(AisdkMessage::user_with_attachments(content, images, audios)); } } crate::session::types::MessageRole::Assistant => { @@ -2677,14 +2759,50 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { mod tests { use super::{ apply_provider_request_defaults, convert_messages, convert_messages_for_model, - is_openai_oauth_model_allowed, maybe_apply_unauthenticated_free_provider_key, - model_supports_image_input, openai_oauth_default_originator, - openai_oauth_model_uses_responses_lite, openai_request_instructions, resolve_api_key, - resolve_model_route, ui_vs_request_model_mismatch_warning, vlm_agent_has_model, - AisdkMessage, OpenAIRequestOptions, ProviderKind, ProviderRequestConfig, + convert_messages_for_model_with_audio, is_openai_oauth_model_allowed, + maybe_apply_unauthenticated_free_provider_key, model_supports_image_input, + openai_oauth_default_originator, openai_oauth_model_uses_responses_lite, + openai_request_instructions, provider_kind_for_model, resolve_api_key, resolve_model_route, + ui_vs_request_model_mismatch_warning, vlm_agent_has_model, AisdkMessage, + OpenAIRequestOptions, ProviderKind, ProviderRequestConfig, }; use crate::persistence::AuthConfig; + use base64::Engine as _; + + #[test] + fn audio_model_receives_base64_audio_attachment() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("sample.wav"); + std::fs::write(&path, b"audio-bytes").unwrap(); + let mut user_message = crate::session::types::Message::user("transcribe"); + user_message.local_audio_paths = vec![path.to_string_lossy().into_owned()]; + + let messages = convert_messages_for_model_with_audio(&[user_message], false, true, false); + let AisdkMessage::User(message) = &messages[0] else { + panic!("expected user message"); + }; + assert_eq!(message.audios.len(), 1); + assert_eq!(message.audios[0].format, "wav"); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(&message.audios[0].data) + .unwrap(), + b"audio-bytes" + ); + } + + #[test] + fn openai_audio_models_use_chat_completions_transport() { + assert_eq!( + provider_kind_for_model("openai", "@ai-sdk/openai", true), + ProviderKind::OpenAICompatible + ); + assert_eq!( + provider_kind_for_model("openai", "@ai-sdk/openai", false), + ProviderKind::OpenAI + ); + } #[test] fn stored_auth_takes_precedence_over_custom_provider_api_key() { diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 193d7969..be2f4fa1 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -784,6 +784,31 @@ impl Discovery { model.limit.as_ref().map(|l| l.context) } + pub fn model_supports_input_modality( + &self, + provider_id: &str, + model_id: &str, + modality: &str, + ) -> bool { + if self + .custom_providers + .as_ref() + .and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase())) + .and_then(|provider| provider.models.get(model_id)) + .and_then(|model| model.modalities.as_ref()) + .is_some_and(|modalities| modalities.input.iter().any(|input| input == modality)) + { + return true; + } + self.load_cache_entry() + .ok() + .flatten() + .and_then(|entry| entry.data.get(provider_id).cloned()) + .and_then(|provider| provider.models.get(model_id).cloned()) + .and_then(|model| model.modalities) + .is_some_and(|modalities| modalities.input.iter().any(|input| input == modality)) + } + pub fn get_model_name(&self, provider_id: &str, model_id: &str) -> Option { if let Some(name) = self .custom_providers @@ -1263,6 +1288,40 @@ mod tests { assert_eq!(model.limit.as_ref().map(|limit| limit.output), Some(8192)); } + #[test] + fn custom_model_modalities_enable_audio_input_lookup() { + let custom_providers = HashMap::from([( + "openai".to_string(), + CustomProviderConfig { + name: None, + npm: Some("@ai-sdk/openai-compatible".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + api_key: None, + models: HashMap::from([( + "audio-model".to_string(), + CustomModelConfig { + name: None, + context_window: None, + max_tokens: None, + attachment: None, + reasoning: None, + reasoning_options: None, + temperature: None, + tool_call: None, + modalities: Some(CustomModelModalities { + input: vec!["text".to_string(), "audio".to_string()], + output: vec!["text".to_string()], + }), + launch: false, + }, + )]), + }, + )]); + let discovery = Discovery::new_with_custom(Some(custom_providers)).unwrap(); + + assert!(discovery.model_supports_input_modality("openai", "audio-model", "audio")); + } + #[test] fn custom_model_attachment_flag_updates_modalities() { let mut providers = HashMap::new(); diff --git a/src/persistence/attachments.rs b/src/persistence/attachments.rs index 80d054f9..4645e3dd 100644 --- a/src/persistence/attachments.rs +++ b/src/persistence/attachments.rs @@ -86,30 +86,35 @@ pub fn clone_messages( let mut created = Vec::new(); for message in &mut cloned { message.id = cuid2::create_id(); - for image_path in &mut message.local_image_paths { - let source = PathBuf::from(&*image_path); - if !is_managed(&source) { - continue; - } - if std::fs::symlink_metadata(&source)?.file_type().is_symlink() { - return Err(anyhow!("managed attachment cannot be a symlink")); - } - let extension = source - .extension() - .and_then(|extension| extension.to_str()) - .ok_or_else(|| anyhow!("managed attachment has no extension"))?; - let data = std::fs::read(&source) - .with_context(|| format!("failed to read attachment {}", source.display()))?; - match write(destination_session_id, extension, &data) { - Ok(path) => { - *image_path = path.to_string_lossy().into_owned(); - created.push(path); + for paths in [ + &mut message.local_image_paths, + &mut message.local_audio_paths, + ] { + for attachment_path in paths { + let source = PathBuf::from(&*attachment_path); + if !is_managed(&source) { + continue; } - Err(error) => { - for path in created { - remove_file(&path); + if std::fs::symlink_metadata(&source)?.file_type().is_symlink() { + return Err(anyhow!("managed attachment cannot be a symlink")); + } + let extension = source + .extension() + .and_then(|extension| extension.to_str()) + .ok_or_else(|| anyhow!("managed attachment has no extension"))?; + let data = std::fs::read(&source) + .with_context(|| format!("failed to read attachment {}", source.display()))?; + match write(destination_session_id, extension, &data) { + Ok(path) => { + *attachment_path = path.to_string_lossy().into_owned(); + created.push(path); + } + Err(error) => { + for path in created { + remove_file(&path); + } + return Err(error); } - return Err(error); } } } diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index d4788adb..86030abd 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -18,6 +18,12 @@ impl From for Message { data: serde_json::json!({ "text": msg.content }), }); } + for path in &msg.local_audio_paths { + parts.push(PersistenceMessagePart { + part_type: "local_audio".to_string(), + data: serde_json::json!({ "path": path }), + }); + } parts } else { msg.parts @@ -44,6 +50,12 @@ impl From for Message { data: serde_json::json!({ "path": path }), }); } + for path in &msg.local_audio_paths { + parts.push(PersistenceMessagePart { + part_type: "local_audio".to_string(), + data: serde_json::json!({ "path": path }), + }); + } if let Some(stats) = msg.compaction_stats { if let Ok(data) = serde_json::to_value(stats) { @@ -115,6 +127,15 @@ impl TryFrom for SessionMessage { data: part.data.clone(), }) .collect(); + let local_audio_paths = session_parts + .iter() + .filter_map(|part| { + (part.part_type == "local_audio") + .then(|| part.data.get("path").and_then(|value| value.as_str())) + .flatten() + }) + .map(str::to_string) + .collect(); let content = session_parts .iter() @@ -229,6 +250,7 @@ impl TryFrom for SessionMessage { model: msg.model.clone(), provider: msg.provider.clone(), local_image_paths, + local_audio_paths, compaction_stats, was_interrupted, }) @@ -290,6 +312,15 @@ mod tests { assert!(restored.usage_authoritative); } + #[test] + fn audio_paths_round_trip_through_persistence() { + let mut session_message = SessionMessage::user("listen"); + session_message.local_audio_paths = vec!["/tmp/audio.wav".to_string()]; + + let restored = SessionMessage::try_from(Message::from(session_message)).unwrap(); + assert_eq!(restored.local_audio_paths, vec!["/tmp/audio.wav"]); + } + #[test] fn compaction_stats_round_trip_through_message_parts() { let stats = CompactionStats { diff --git a/src/remote/mod.rs b/src/remote/mod.rs index 02586dad..5d4a3b82 100644 --- a/src/remote/mod.rs +++ b/src/remote/mod.rs @@ -304,6 +304,7 @@ struct RemoteMessage { model: Option, provider: Option, local_image_paths: Vec, + local_audio_paths: Vec, was_interrupted: bool, parts: Vec, } @@ -3305,6 +3306,7 @@ fn remote_message(message: &Message) -> RemoteMessage { model: message.model.clone(), provider: message.provider.clone(), local_image_paths: message.local_image_paths.clone(), + local_audio_paths: message.local_audio_paths.clone(), was_interrupted: message.was_interrupted, parts: message.parts.clone(), } @@ -4121,10 +4123,12 @@ mod tests { fn remote_message_includes_local_image_paths() { let mut message = Message::user("see [Image #1]"); message.local_image_paths = vec!["/tmp/example.png".to_string()]; + message.local_audio_paths = vec!["/tmp/example.wav".to_string()]; let remote = remote_message(&message); assert_eq!(remote.local_image_paths, vec!["/tmp/example.png"]); + assert_eq!(remote.local_audio_paths, vec!["/tmp/example.wav"]); } #[test] diff --git a/src/session/compaction.rs b/src/session/compaction.rs index 2acd8474..b6968037 100644 --- a/src/session/compaction.rs +++ b/src/session/compaction.rs @@ -172,7 +172,6 @@ fn select_messages_with_budget( kept_tokens = kept_tokens.saturating_add(size); } } - // OpenCode: if no keep, or keep would start at work index 0, summarize all. let tail_start_work = match tail_start_work { Some(0) | None => work_indices.len(), @@ -556,6 +555,18 @@ fn message_content_for_prompt(message: &Message) -> String { } } + if !message.local_audio_paths.is_empty() { + if !content.trim().is_empty() { + content.push('\n'); + } + content.push_str("Attached local audio:\n"); + for path in &message.local_audio_paths { + content.push_str("- "); + content.push_str(path); + content.push('\n'); + } + } + content } diff --git a/src/session/types.rs b/src/session/types.rs index 85648bdd..c2db52ba 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -186,6 +186,7 @@ pub struct Message { pub model: Option, pub provider: Option, pub local_image_paths: Vec, + pub local_audio_paths: Vec, pub compaction_stats: Option, pub was_interrupted: bool, } @@ -239,6 +240,7 @@ impl Message { model: None, provider: None, local_image_paths: Vec::new(), + local_audio_paths: Vec::new(), compaction_stats: None, was_interrupted: false, } @@ -293,6 +295,7 @@ impl Message { model: None, provider: None, local_image_paths: Vec::new(), + local_audio_paths: Vec::new(), compaction_stats: None, was_interrupted: false, } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7d7b61a7..9944ae27 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -2268,6 +2268,8 @@ impl Chat { std::mem::discriminant(&msg.role).hash(&mut h); msg.content.hash(&mut h); msg.reasoning.hash(&mut h); + msg.local_image_paths.hash(&mut h); + msg.local_audio_paths.hash(&mut h); for part in &msg.parts { part.part_type.hash(&mut h); part.data.to_string().hash(&mut h); From 5a1af7ab44ea91228f515c2d492e6aa9b8a0a065 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 13:37:30 +0700 Subject: [PATCH 15/26] feat(acp): show permission edit metadata --- _docs/acp.mdx | 2 +- src/acp/service.rs | 121 +++++++++++++++++++++++++++++++-- src/app.rs | 6 ++ src/tools/permission.rs | 13 ++++ src/views/permission_dialog.rs | 14 ++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 08be4511..c8830844 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -47,7 +47,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Prompts | Text, embedded text resources, PNG/JPEG/GIF/WebP images, and WAV or MP3 audio attachments; assistant text and reasoning stream back to the editor. ACP attachments use private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images and audio require matching selected-model input modalities. Audio currently uses the verified OpenAI-compatible Chat Completions `input_audio` transport; Responses-only and Anthropic transports reject it. Legacy sessions may still reference external or temporary image paths created by older versions. | Add additional verified provider audio transports and migrate readable legacy temporary attachments into managed storage. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | | Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | -| Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID and allow once, always allow, and reject choices. | Permission requests do not include edit patch metadata yet. | Carry edit patch metadata through permission preflight. | +| Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID, raw tool input, normalized file locations, preflight full-file diffs for edit/write/write_files, and allow once, always allow, or reject choices. | `apply_patch` permissions include the raw patch and target locations, but ACP's native diff shape requires full before/after file text and is therefore emitted after execution. | Add a safe preflight patch simulator if ACP clients need native multi-file diffs before approving `apply_patch`. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | | Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | | MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 6c5b5cb9..b997d36b 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,76 @@ pub struct AcpService { client_capabilities: Arc>, } +fn permission_tool_content( + prompt: &crate::tools::PermissionPrompt, + cwd: &Path, +) -> Vec { + match prompt.tool_id.as_str() { + "write" => prompt + .raw_input + .get("file_path") + .or_else(|| prompt.raw_input.get("filePath")) + .and_then(serde_json::Value::as_str) + .zip( + prompt + .raw_input + .get("content") + .and_then(serde_json::Value::as_str), + ) + .map(|(path, new_text)| vec![preflight_diff(path, new_text, cwd)]) + .unwrap_or_default(), + "write_files" => prompt + .raw_input + .get("files") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|file| { + let path = file.get("file_path")?.as_str()?; + let content = file.get("content")?.as_str()?; + Some(preflight_diff(path, content, cwd)) + }) + .collect(), + "edit" => permission_edit_diff(&prompt.raw_input, cwd) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +fn preflight_diff(path: &str, new_text: &str, cwd: &Path) -> ToolCallContent { + let path = absolute_tool_path(path, cwd); + let old_text = std::fs::read_to_string(&path).ok(); + agent_client_protocol::schema::v1::Diff::new(path, new_text.to_string()) + .old_text(old_text) + .into() +} + +fn permission_edit_diff(input: &serde_json::Value, cwd: &Path) -> Option { + let path = input + .get("file_path") + .or_else(|| input.get("filePath"))? + .as_str()?; + let old_string = input.get("old_string")?.as_str()?; + let new_string = input.get("new_string")?.as_str()?; + let absolute = absolute_tool_path(path, cwd); + let old_text = std::fs::read_to_string(&absolute).ok()?; + let new_text = if input + .get("replace_all") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + old_text.replace(old_string, new_string) + } else { + old_text.replacen(old_string, new_string, 1) + }; + Some( + agent_client_protocol::schema::v1::Diff::new(absolute, new_text) + .old_text(old_text) + .into(), + ) +} + fn write_prompt_audio( session_id: &str, audio: &agent_client_protocol::schema::v1::AudioContent, @@ -1937,15 +2007,22 @@ async fn request_permission( "command": prompt.command, "workdir": prompt.workdir, "reason": prompt.reason, + "input": prompt.raw_input, }); - let tool_call = ToolCallUpdate::new( - tool_call_id, - ToolCallUpdateFields::new() + let cwd = PathBuf::from(&prompt.workspace); + let content = permission_tool_content(prompt, &cwd); + let tool_call = ToolCallUpdate::new(tool_call_id, { + let mut fields = ToolCallUpdateFields::new() .title(permission_title(prompt)) .kind(tool_kind(&prompt.tool_id)) .status(ToolCallStatus::Pending) - .raw_input(input), - ); + .locations(tool_locations(&prompt.tool_id, &prompt.raw_input, &cwd)) + .raw_input(input); + if !content.is_empty() { + fields = fields.content(content); + } + fields + }); let request = RequestPermissionRequest::new( session_id.to_string(), tool_call, @@ -2560,6 +2637,40 @@ mod tests { assert!(permission_tool_call_id(None).starts_with("permission:")); } + #[test] + fn acp_permission_edit_includes_preflight_diff() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("main.rs"); + std::fs::write(&path, "fn old() {}\n").unwrap(); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let prompt = crate::tools::PermissionPrompt { + tool_call_id: Some("call_edit".to_string()), + tool_id: "edit".to_string(), + action: crate::tools::PermissionAction::Write, + permission: "edit".to_string(), + patterns: vec![path.to_string_lossy().into_owned()], + target: Some(path.to_string_lossy().into_owned()), + command: None, + workdir: None, + workspace: dir.path().to_string_lossy().into_owned(), + reason: "approval".to_string(), + raw_input: serde_json::json!({ + "file_path": path, + "old_string": "old", + "new_string": "new", + "replace_all": false + }), + response_tx, + }; + + let content = permission_tool_content(&prompt, dir.path()); + let ToolCallContent::Diff(diff) = &content[0] else { + panic!("expected preflight diff"); + }; + assert_eq!(diff.old_text.as_deref(), Some("fn old() {}\n")); + assert_eq!(diff.new_text, "fn new() {}\n"); + } + #[test] fn acp_question_form_preserves_single_multi_custom_and_scope() { let form = acp_question_form( diff --git a/src/app.rs b/src/app.rs index bb2be8af..238a1d6f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12504,7 +12504,9 @@ mod tests { target: Some("/tmp".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx: permission_tx, }); let (question_tx, _question_rx) = tokio::sync::oneshot::channel(); @@ -12536,7 +12538,9 @@ mod tests { target: Some("/tmp".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx: permission_tx, }); let (question_tx, _question_rx) = tokio::sync::oneshot::channel(); @@ -13162,7 +13166,9 @@ mod tests { target: Some("/tmp".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx: permission_tx, }); app.overlay_focus = OverlayFocus::PermissionDialog; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 9a2d76be..2414d407 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -106,7 +106,9 @@ pub struct PermissionPrompt { pub target: Option, pub command: Option, pub workdir: Option, + pub workspace: String, pub reason: String, + pub raw_input: Value, pub response_tx: tokio::sync::oneshot::Sender, } @@ -376,6 +378,7 @@ impl ToolPermissions { PermissionReasonKind::ConfiguredAsk, path.as_deref(), command.clone(), + params, tool_call_id, sender, ) @@ -421,6 +424,7 @@ impl ToolPermissions { reason_kind, reason_path.as_deref().or(path.as_deref()), command.clone(), + params, tool_call_id, sender, ) @@ -457,6 +461,7 @@ impl ToolPermissions { reason_kind, path.as_deref(), command, + params, tool_call_id, sender, ) @@ -475,6 +480,7 @@ impl ToolPermissions { reason_kind: PermissionReasonKind, path: Option<&Path>, command: Option, + params: &Value, tool_call_id: Option<&str>, sender: Option<&ChunkSender>, ) -> Result<(), ToolError> { @@ -540,7 +546,9 @@ impl ToolPermissions { target: prompt_target, command, workdir, + workspace: self.workdir.to_string_lossy().into_owned(), reason: reason_text, + raw_input: params.clone(), response_tx, }; @@ -1330,6 +1338,11 @@ mod tests { _ => panic!("Expected permission prompt"), }; assert_eq!(prompt.tool_call_id.as_deref(), Some("call_123")); + assert_eq!( + prompt.raw_input, + serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }) + ); + assert_eq!(prompt.workspace, "/tmp/workspace"); let _ = prompt.response_tx.send(PermissionResponse::Deny); assert!(pending .await diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs index 2ab3c787..220043d6 100644 --- a/src/views/permission_dialog.rs +++ b/src/views/permission_dialog.rs @@ -564,7 +564,9 @@ mod tests { target: Some("cargo test".to_string()), command: Some("cargo test".to_string()), workdir: Some("/tmp/workspace".to_string()), + workspace: "/tmp/workspace".to_string(), reason: "Bash command execution requires permission".to_string(), + raw_input: serde_json::Value::Null, response_tx, }; let colors = Theme::load_builtin_default().get_colors(true); @@ -598,7 +600,9 @@ mod tests { target: Some("/Users/carlo/Desktop/Projects/sheetpilot".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "Tool 'read' wants to access path outside working directory".to_string(), + raw_input: serde_json::Value::Null, response_tx, }; let colors = Theme::load_builtin_default().get_colors(true); @@ -624,7 +628,9 @@ mod tests { target: Some("cargo test".to_string()), command: Some("cargo test".to_string()), workdir: Some("/tmp/workspace".to_string()), + workspace: "/tmp/workspace".to_string(), reason: "Bash command execution requires permission".to_string(), + raw_input: serde_json::Value::Null, response_tx, }); @@ -669,7 +675,9 @@ mod tests { target: Some("/tmp/file".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "explicit approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx, }); @@ -701,7 +709,9 @@ mod tests { target: Some("/Users/carlo/Desktop/Projects/sheetpilot/README.md".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "Tool 'read' wants to access path outside working directory: /Users/carlo/Desktop/Projects/sheetpilot/README.md".to_string(), + raw_input: serde_json::Value::Null, response_tx, }); let colors = Theme::load_builtin_default().get_colors(true); @@ -742,7 +752,9 @@ mod tests { target: Some("/tmp/file".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "explicit approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx, }); let colors = Theme::load_builtin_default().get_colors(true); @@ -784,7 +796,9 @@ mod tests { target: Some("/tmp/file".to_string()), command: None, workdir: None, + workspace: "/tmp".to_string(), reason: "explicit approval required".to_string(), + raw_input: serde_json::Value::Null, response_tx, }); let colors = Theme::load_builtin_default().get_colors(true); From 6e2ed01f41e4dba6e7baf42ac9a86a882ad2e7e0 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 13:44:16 +0700 Subject: [PATCH 16/26] test(acp): cover stdio subprocess lifecycle --- _docs/acp.mdx | 2 +- tests/acp_stdio.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/acp_stdio.rs diff --git a/_docs/acp.mdx b/_docs/acp.mdx index c8830844..8e5a013a 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -42,7 +42,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Area | Supported behavior | Current limitations | Planned follow-up | | --- | --- | --- | --- | -| Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown. | stdout must remain protocol-only. | Add protocol-level subprocess integration coverage. | +| Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown is covered by a real subprocess initialize/response integration test. | stdout must remain protocol-only. | Add broader editor compatibility fixtures and malformed-request coverage. | | Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | | Prompts | Text, embedded text resources, PNG/JPEG/GIF/WebP images, and WAV or MP3 audio attachments; assistant text and reasoning stream back to the editor. ACP attachments use private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images and audio require matching selected-model input modalities. Audio currently uses the verified OpenAI-compatible Chat Completions `input_audio` transport; Responses-only and Anthropic transports reject it. Legacy sessions may still reference external or temporary image paths created by older versions. | Add additional verified provider audio transports and migrate readable legacy temporary attachments into managed storage. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | diff --git a/tests/acp_stdio.rs b/tests/acp_stdio.rs new file mode 100644 index 00000000..3e357dad --- /dev/null +++ b/tests/acp_stdio.rs @@ -0,0 +1,87 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +#[test] +fn initialize_over_stdio_and_shutdown_on_eof() { + let workspace = tempfile::tempdir().expect("workspace"); + let home = tempfile::tempdir().expect("home"); + let config = tempfile::tempdir().expect("config"); + let state = tempfile::tempdir().expect("state"); + let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode")) + .args(["acp", "--cwd"]) + .arg(workspace.path()) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", config.path()) + .env("XDG_STATE_HOME", state.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn crabcode acp"); + + let stdout = child.stdout.take().expect("stdout"); + let (line_tx, line_rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout).read_line(&mut line).map(|_| line); + let _ = line_tx.send(result); + }); + + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {} + } + }); + let mut stdin = child.stdin.take().expect("stdin"); + writeln!(stdin, "{request}").expect("write initialize"); + stdin.flush().expect("flush initialize"); + + let line = match line_rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(line)) => line, + Ok(Err(error)) => { + let _ = child.kill(); + panic!("failed reading ACP response: {error}"); + } + Err(_) => { + let _ = child.kill(); + panic!("timed out waiting for ACP initialize response"); + } + }; + let response: serde_json::Value = serde_json::from_str(line.trim()).unwrap_or_else(|error| { + let _ = child.kill(); + panic!("invalid protocol response {line:?}: {error}"); + }); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["protocolVersion"], 1); + assert_eq!(response["result"]["agentInfo"]["name"], "crabcode"); + assert_eq!( + response["result"]["agentInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!(response["result"]["agentCapabilities"]["loadSession"], true); + assert_eq!( + response["result"]["agentCapabilities"]["promptCapabilities"]["audio"], + true + ); + + drop(stdin); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().expect("poll ACP process") { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + panic!("ACP process did not shut down after stdin EOF"); + } + std::thread::sleep(Duration::from_millis(20)); + }; + assert!(status.success(), "ACP exited with {status}"); +} From 2680c45a12f74a84348ebb3566b39903f993969c Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 13:58:32 +0700 Subject: [PATCH 17/26] feat(acp): preserve structured MCP results --- _docs/acp.mdx | 2 +- src/acp/service.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++-- src/mcp/mod.rs | 71 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 8e5a013a..4823796b 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -46,7 +46,7 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP | Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | | Prompts | Text, embedded text resources, PNG/JPEG/GIF/WebP images, and WAV or MP3 audio attachments; assistant text and reasoning stream back to the editor. ACP attachments use private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images and audio require matching selected-model input modalities. Audio currently uses the verified OpenAI-compatible Chat Completions `input_audio` transport; Responses-only and Anthropic transports reject it. Legacy sessions may still reference external or temporary image paths created by older versions. | Add additional verified provider audio transports and migrate readable legacy temporary attachments into managed storage. | | Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | -| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. | MCP tools only expose structured content when their result can be normalized into Crabcode's tool result model. | Preserve richer MCP tool resources, annotations, and image content. | +| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. MCP structured content, resources, annotations, metadata, and images are retained; supported MCP blocks are restored as native ACP content. | MCP audio blocks and unknown future content types remain available in raw output but are not yet rendered as dedicated Crabcode tool-result media. | Extend the generic tool result model when additional MCP/ACP content types become stable. | | Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID, raw tool input, normalized file locations, preflight full-file diffs for edit/write/write_files, and allow once, always allow, or reject choices. | `apply_patch` permissions include the raw patch and target locations, but ACP's native diff shape requires full before/after file text and is therefore emitted after execution. | Add a safe preflight patch simulator if ACP clients need native multi-file diffs before approving `apply_patch`. | | Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | | Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | diff --git a/src/acp/service.rs b/src/acp/service.rs index b997d36b..41ccf53d 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,39 @@ pub struct AcpService { client_capabilities: Arc>, } +fn mcp_native_tool_content(metadata: &serde_json::Value) -> Vec { + metadata + .get("mcp_result") + .and_then(|result| result.get("content")) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|block| { + !matches!( + block.get("type").and_then(serde_json::Value::as_str), + Some("text") + ) + }) + .filter_map(|block| { + serde_json::from_value::(block.clone()) + .ok() + .map(ToolCallContent::from) + }) + .collect() +} + +fn metadata_has_mcp_images(metadata: Option<&serde_json::Value>) -> bool { + metadata + .and_then(|metadata| metadata.get("mcp_result")) + .and_then(|result| result.get("content")) + .and_then(serde_json::Value::as_array) + .is_some_and(|content| { + content + .iter() + .any(|block| block.get("type").and_then(serde_json::Value::as_str) == Some("image")) + }) +} + fn permission_tool_content( prompt: &crate::tools::PermissionPrompt, cwd: &Path, @@ -1936,10 +1969,13 @@ fn tool_result_content(payload: &serde_json::Value, cwd: &Path) -> Vec ToolResult { + let text = call_tool_result_text(result); + let output = if let Some(structured) = result.structured_content.as_ref() { + let structured = + serde_json::to_string_pretty(structured).unwrap_or_else(|_| structured.to_string()); + if text.trim().is_empty() || text == structured { + structured } else { - call_tool_result_text(&result) - }; - Ok(ToolResult::new( - format!("MCP: {server_name}.{tool_name}"), - output, - )) + format!("{structured}\n\n{text}") + } + } else { + text + }; + let mut tool_result = ToolResult::new(format!("MCP: {server_name}.{tool_name}"), output) + .with_metadata( + "mcp_result", + serde_json::to_value(&result).unwrap_or(serde_json::Value::Null), + ); + for content in &result.content { + if let ContentBlock::Image(image) = content { + tool_result = tool_result.with_image( + format!("data:{};base64,{}", image.mime_type, image.data), + image.mime_type.clone(), + ); + } } + tool_result } type ConnectOutcome = Result<(RunningService, Vec), McpStatus>; @@ -754,6 +779,36 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn mcp_result_preserves_images_resources_annotations_and_structured_content() { + let resource = rmcp::model::Resource::new("file:///tmp/readme.md", "readme") + .with_mime_type("text/markdown"); + let mut result = rmcp::model::CallToolResult::success(vec![ + ContentBlock::Image( + rmcp::model::ImageContent::new("aGk=", "image/png") + .with_annotations(rmcp::model::Annotations::default().with_priority(0.8)), + ), + ContentBlock::ResourceLink(resource), + ]); + result.structured_content = Some(json!({"answer": 42})); + + let converted = mcp_tool_result("docs", "lookup", &result); + assert_eq!(converted.images.len(), 1); + assert_eq!(converted.images[0].media_type, "image/png"); + assert_eq!( + converted.metadata["mcp_result"]["structuredContent"]["answer"], + 42 + ); + let priority = converted.metadata["mcp_result"]["content"][0]["annotations"]["priority"] + .as_f64() + .unwrap(); + assert!((priority - 0.8).abs() < 0.000_001); + assert_eq!( + converted.metadata["mcp_result"]["content"][1]["uri"], + "file:///tmp/readme.md" + ); + } + #[test] fn normalize_strips_root_anyof_with_non_object_branches() { // Mirrors cua-driver `browser_prepare`: object root + anyOf of required-only From 202402f4250927a5f9b6fc447fed270ea5104612 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Wed, 2 Sep 2026 15:50:19 +0700 Subject: [PATCH 18/26] feat(acp): complete capability matrix --- _docs/acp.mdx | 41 ++- src/acp/server.rs | 65 +++- src/acp/service.rs | 532 ++++++++++++++++++++++++------ src/aisdk/chunk.rs | 14 + src/aisdk/providers/compatible.rs | 27 +- src/aisdk/providers/openai.rs | 30 +- src/llm/client.rs | 60 +++- src/llm/mod.rs | 1 + src/persistence/attachments.rs | 156 +++++++++ src/persistence/conversions.rs | 69 +++- src/session/manager.rs | 55 ++- src/tools/patch.rs | 297 ++++++++++++++++- src/tools/question.rs | 90 ++++- 13 files changed, 1256 insertions(+), 181 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 4823796b..a46b5c62 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -40,24 +40,33 @@ Configure a provider in the Crabcode TUI with `/connect` before starting an ACP ## Capability Matrix -| Area | Supported behavior | Current limitations | Planned follow-up | +All Crabcode-side capabilities in this matrix are implemented. Conditions in the last column are editor, model, provider, or protocol requirements rather than incomplete server behavior. + +| Area | Full behavior | Status | Runtime or protocol requirements | | --- | --- | --- | --- | -| Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown is covered by a real subprocess initialize/response integration test. | stdout must remain protocol-only. | Add broader editor compatibility fixtures and malformed-request coverage. | -| Sessions | Create, list, load, resume, close, and fork persisted root sessions; message IDs remain stable across live streaming, persistence snapshots, and reload replay. | Session operations are limited to persisted root sessions. | Add richer session metadata and nested-session navigation. | -| Prompts | Text, embedded text resources, PNG/JPEG/GIF/WebP images, and WAV or MP3 audio attachments; assistant text and reasoning stream back to the editor. ACP attachments use private session-managed state, survive load/resume, are copied independently on fork, and are removed when the persisted session is deleted. | Images and audio require matching selected-model input modalities. Audio currently uses the verified OpenAI-compatible Chat Completions `input_audio` transport; Responses-only and Anthropic transports reject it. Legacy sessions may still reference external or temporary image paths created by older versions. | Add additional verified provider audio transports and migrate readable legacy temporary attachments into managed storage. | -| Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | -| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, full textual output, bounded previews, normalized file locations, result images, and full-file diffs for built-in file mutation tools. MCP structured content, resources, annotations, metadata, and images are retained; supported MCP blocks are restored as native ACP content. | MCP audio blocks and unknown future content types remain available in raw output but are not yet rendered as dedicated Crabcode tool-result media. | Extend the generic tool result model when additional MCP/ACP content types become stable. | -| Permissions | Existing Crabcode permission prompts are forwarded with the originating tool-call ID, raw tool input, normalized file locations, preflight full-file diffs for edit/write/write_files, and allow once, always allow, or reject choices. | `apply_patch` permissions include the raw patch and target locations, but ACP's native diff shape requires full before/after file text and is therefore emitted after execution. | Add a safe preflight patch simulator if ACP clients need native multi-file diffs before approving `apply_patch`. | -| Cancellation | `session/cancel` cancels the active turn and keeps the session reusable; provider output limits and refusals are returned as ACP `max_tokens` and `refusal` stop reasons. | Other provider-specific terminal reasons still reduce to normal completion or a safe failure. | Preserve additional provider-specific terminal semantics where ACP gains matching stop reasons. | -| Commands and skills | Session updates publish project custom slash commands, workspace skills, and built-in `/skills`, `/mcp`, and `/compact`. Template commands expand before model turns; `/compact` rewrites persisted model context without adding a literal user message. | Other TUI-only commands are not ACP-available; unknown `/…` lines pass through as plain text. | Add more built-in commands and richer command input schemas. | -| MCP | Project MCP from Crabcode config runs as usual. Editors may also pass MCP servers on `session/new`; those servers are merged into the session config (stdio, HTTP, and SSE). | HTTP and SSE client MCP are advertised; stdio client MCP is accepted and merged even though it is not a separate advertised capability flag. | Surface richer MCP connection status and OAuth for remote client servers. | -| Terminals | Interactive `terminal_session` and `bash` terminal calls run through the editor's ACP terminal host, embed in the originating tool call, retain bounded output for the model, and support cancellation with kill-and-release cleanup. | Requires an editor that advertises ACP terminal support; editors without it safely stop the request, and terminal input or resize is user-driven through the embedded editor terminal rather than agent-issued protocol requests. | Surface richer terminal lifecycle metadata and adopt protocol input/resize controls if ACP adds them. | -| Questions | Agent questions are forwarded as ACP form elicitations with ordered single-select, multi-select, and custom-text answers when the editor advertises form elicitation support. | ACP elicitation is currently unstable; editors without form support receive a safe skipped response instead of blocking the turn. | Adopt the stable elicitation capability when ACP finalizes it and surface richer validation or defaults. | -| Usage | Provider-reported input, output, cache-read, and cache-write tokens are aggregated across multi-step turns and persisted per assistant message; model-catalog pricing produces cache-aware session cost totals that are emitted through ACP's cumulative USD cost field. Context-window occupancy continues to use Crabcode's transcript estimate. | Some providers or local models do not return usage, and locally computed cost is unavailable when the selected model has no pricing metadata; ACP does not expose the detailed token/cache breakdown in its standard usage update. | Adopt provider-reported monetary totals where available and expose detailed billing metadata if ACP standardizes it. | +| Transport | JSON-RPC over stdio through `crabcode acp`, protocol-only stdout, clean stdin EOF shutdown, and subprocess initialize/response coverage. | Full | The subprocess wrapper must not write banners or logs to stdout. | +| Sessions | Create, cursor-list, load, resume, close, delete, and fork all persisted sessions, including child sessions. Lists include Crabcode parent/root IDs in ACP `_meta`; forks preserve the source title, regenerate message IDs, copy attachments independently, and publish commands for the new session. Delete removes persisted history and managed attachments. | Full | ACP has no standard nested-session tree field, so hierarchy is exposed through the `crabcode` metadata extension while the standard list remains flat. | +| Prompts | Text, embedded resources, PNG/JPEG/GIF/WebP images, and WAV/MP3 audio; assistant text and reasoning stream back to the editor. Attachments use private per-session storage, survive load/resume, copy independently on fork, delete with persisted sessions, and readable legacy paths migrate automatically on load. | Full | The selected model route must advertise the matching input modality. Audio uses verified OpenAI-compatible Chat Completions `input_audio`; unsupported provider transports return a clear error instead of dropping media. | +| Modes and models | Visible primary agents, selectable model catalog entries, and supported reasoning-effort values are session-local ACP configuration options. | Full | Available reasoning values follow the selected model's catalog capability. | +| Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | +| Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow, and reject choices. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | +| Cancellation | `session/cancel` interrupts model turns, questions, compaction, and terminal creation/execution while keeping the session reusable. Crabcode maps completion, output limit, configured turn limit, refusal/content filtering, and cancellation to ACP `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. | Full | Provider failures that are not normal stop conditions remain JSON-RPC/tool errors, as required by ACP's stop-reason model. | +| Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, and `/compact`. Custom command agent/model overrides apply to that turn. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | +| MCP | Project MCP and client-supplied stdio, HTTP, and SSE servers merge into the session. Static headers, structured results, live status, resources, annotations, images, audio, and metadata are preserved. Project-configured remote MCP continues to use Crabcode's OAuth credential flow. | Full | ACP currently advertises only the HTTP/SSE transport flags; its client-server schema has no stdio flag or remote OAuth fields. Client-supplied remote auth can still be provided through headers. | +| Terminals | `terminal_session` and terminal-mode `bash` use the editor terminal host through create, embed, wait, output, kill, and release. Output is bounded for the model, and cancellation also covers terminal creation. | Full | The editor must advertise terminal hosting. User input and resize happen directly in the embedded editor terminal because ACP has no agent-issued stdin/resize requests. | +| Questions | Agent questions use capability-gated ACP form elicitation with validated non-empty prompts/options, unique labels, ordered single/multi-select answers, custom text, cardinality checks, deduplication, length bounds, cancellation, and safe skip behavior. | Full | Form elicitation is an unstable ACP capability and is only sent to editors that advertise it. | +| Usage | Provider input/output/cache-read/cache-write usage is aggregated across multi-step turns and persisted. ACP always receives context occupancy and cumulative USD cost updates; detailed token/cache values and whether the context size is known are included in `crabcode` `_meta`. Catalog pricing is cache-aware. | Full | Providers that omit usage or models without pricing cannot supply authoritative token or cost data; Crabcode still emits estimated context occupancy and marks unknown context size in metadata. | + +## Runtime requirements + +- Image and audio prompts require a selected model route with the corresponding input modality. +- ACP terminal embedding and question forms require the editor to advertise those client capabilities during initialization. +- Client-supplied remote MCP OAuth parameters are not part of the current ACP server descriptor. Use static headers from the editor, or configure the MCP server in Crabcode to use Crabcode's OAuth flow. +- Fields under `_meta.crabcode` are backwards-compatible Crabcode extensions for session hierarchy and detailed usage accounting. ## Session behavior -`session/close` detaches the editor and cancels any active turn. It does not delete Crabcode session history. Load replays the stored transcript; resume restores the session configuration without replaying prior content. Fork creates a new persisted session with a copied transcript. +`session/close` detaches the editor and cancels any active turn. It does not delete Crabcode session history. `session/delete` removes persisted history and managed attachments. List cursors page through the complete non-archived result set. Load replays the stored transcript; resume restores the session configuration without replaying prior content. Fork creates a new persisted session with a copied transcript and independently managed attachments. Child sessions are listed as normal entries with hierarchy metadata under `_meta.crabcode`. ## Safety notes @@ -67,6 +76,6 @@ Question forms are only sent to editors that advertise ACP form elicitation supp Client-supplied MCP servers run with the same trust as project-configured MCP: stdio servers can execute local processes, and remote servers can send the headers and credentials the editor provides. Only attach MCP servers you trust for that workspace. -Image and audio attachments are decoded under a 20 MiB-per-file limit and written to private session-managed storage under Crabcode's state directory (`…/crabcode/attachments//`). Audio input accepts WAV and MP3 only. Closing an editor session keeps those files because history remains loadable; deleting the persisted session removes its managed attachment directory. Forks receive independent copies so deleting either session does not break the other. +Image and audio attachments are decoded under a 20 MiB-per-file limit and written to private session-managed storage under Crabcode's state directory (`…/crabcode/attachments//`). Audio input accepts WAV and MP3 only. Closing an editor session keeps those files because history remains loadable; deleting the persisted session removes its managed attachment directory. Forks receive independent copies so deleting either session does not break the other. Readable external or temporary attachment paths from older sessions are copied into managed storage the next time Crabcode loads the session. -The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image, audio, and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close session ops). Client-side form elicitation and terminal hosting are capability-gated during initialization before Crabcode sends those requests. +The capability matrix matches what the ACP server implements today. Crabcode only advertises protocol capability flags it handles (`loadSession`, image, audio, and embedded-context prompts, HTTP/SSE MCP, and list/resume/fork/close/delete session ops). Client-side form elicitation and terminal hosting are capability-gated during initialization before Crabcode sends those requests. Stdio MCP is accepted even though the current ACP capability object has no separate stdio flag. diff --git a/src/acp/server.rs b/src/acp/server.rs index 522dce37..49c2cfc0 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -1,11 +1,12 @@ use agent_client_protocol::schema::v1::{ AgentCapabilities, CancelNotification, CloseSessionRequest, CloseSessionResponse, - ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, - ListSessionsRequest, LoadSessionRequest, McpCapabilities, NewSessionRequest, - PromptCapabilities, PromptRequest, ResumeSessionRequest, SessionCapabilities, - SessionCloseCapabilities, SessionForkCapabilities, SessionListCapabilities, - SessionNotification, SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest, - SetSessionModeRequest, SetSessionModeResponse, + DeleteSessionRequest, DeleteSessionResponse, ForkSessionRequest, ForkSessionResponse, + Implementation, InitializeRequest, InitializeResponse, ListSessionsRequest, LoadSessionRequest, + McpCapabilities, NewSessionRequest, PromptCapabilities, PromptRequest, ResumeSessionRequest, + SessionCapabilities, SessionCloseCapabilities, SessionDeleteCapabilities, + SessionForkCapabilities, SessionListCapabilities, SessionNotification, + SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest, + SetSessionModeResponse, }; use agent_client_protocol::{Agent, Stdio}; use anyhow::{Context, Result}; @@ -48,16 +49,39 @@ pub async fn run(cwd: Option) -> Result<()> { .on_receive_request( { let service = service.clone(); - async move |request: ForkSessionRequest, responder, _connection| { - let result = service - .fork_session(request.session_id.to_string(), request.cwd) - .await - .map(|response| { - ForkSessionResponse::new(response.session_id) + async move |request: DeleteSessionRequest, responder, _connection| { + responder.respond_with_result( + service + .delete_session(&request.session_id.to_string()) + .await + .map(|_| DeleteSessionResponse::new()), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let service = service.clone(); + async move |request: ForkSessionRequest, responder, connection| { + let service = service.clone(); + let task_connection = connection.clone(); + connection.spawn(async move { + let response = service + .fork_session(request.session_id.to_string(), request.cwd) + .await?; + let session_id = response.session_id.clone(); + let commands = service.available_commands(&session_id.to_string()).await?; + responder.respond( + ForkSessionResponse::new(session_id.clone()) .modes(response.modes) - .config_options(response.config_options) - }); - responder.respond_with_result(result) + .config_options(response.config_options), + )?; + task_connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(commands), + )) + }) } }, agent_client_protocol::on_receive_request!(), @@ -190,7 +214,9 @@ pub async fn run(cwd: Option) -> Result<()> { { let service = service.clone(); async move |request: ListSessionsRequest, responder, _connection| { - responder.respond_with_result(service.list_sessions(request.cwd).await) + responder.respond_with_result( + service.list_sessions(request.cwd, request.cursor).await, + ) } }, agent_client_protocol::on_receive_request!(), @@ -254,7 +280,8 @@ fn capabilities() -> AgentCapabilities { .list(SessionListCapabilities::new()) .resume(SessionResumeCapabilities::new()) .fork(SessionForkCapabilities::new()) - .close(SessionCloseCapabilities::new()), + .close(SessionCloseCapabilities::new()) + .delete(SessionDeleteCapabilities::new()), ) } @@ -264,8 +291,10 @@ mod tests { #[test] fn advertises_audio_prompt_support() { - let prompt = capabilities().prompt_capabilities; + let capabilities = capabilities(); + let prompt = capabilities.prompt_capabilities; assert!(prompt.audio); assert!(prompt.image); + assert!(capabilities.session_capabilities.delete.is_some()); } } diff --git a/src/acp/service.rs b/src/acp/service.rs index 41ccf53d..acbb5039 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,58 @@ pub struct AcpService { client_capabilities: Arc>, } +fn command_text(parts: &[ContentBlock]) -> String { + let mut text = String::new(); + for part in parts { + match part { + ContentBlock::Text(content) => text.push_str(&content.text), + ContentBlock::ResourceLink(link) => text.push_str(&format!("[{}]", link.uri)), + ContentBlock::Resource(resource) => match &resource.resource { + EmbeddedResourceResource::TextResourceContents(resource) => { + text.push_str(&format!("[{}]\n{}", resource.uri, resource.text)); + } + EmbeddedResourceResource::BlobResourceContents(resource) => { + text.push_str(&format!("[{}]", resource.uri)); + } + _ => {} + }, + _ => {} + } + } + text +} + +fn acp_session_info( + session: crate::session::manager::SessionInfo, + root_id: Option, +) -> SessionInfo { + let mut meta = serde_json::Map::new(); + meta.insert( + "crabcode".to_string(), + serde_json::json!({ + "parentSessionId": session.parent_id, + "rootSessionId": root_id, + }), + ); + SessionInfo::new(session.id, session.workspace_path) + .title(session.title) + .updated_at(system_time_to_iso8601(session.updated_at)) + .meta(meta) +} + +fn session_page( + cursor: Option<&str>, + total: usize, +) -> Result<(usize, usize, Option), Error> { + let offset = cursor + .unwrap_or("0") + .parse::() + .map_err(|_| Error::invalid_params().data("invalid session list cursor"))?; + let end = offset.saturating_add(100).min(total); + let next_cursor = (end < total).then(|| end.to_string()); + Ok((offset.min(total), end, next_cursor)) +} + fn mcp_native_tool_content(metadata: &serde_json::Value) -> Vec { metadata .get("mcp_result") @@ -43,10 +95,19 @@ fn mcp_native_tool_content(metadata: &serde_json::Value) -> Vec Some("text") ) }) - .filter_map(|block| { + .map(|block| { serde_json::from_value::(block.clone()) - .ok() .map(ToolCallContent::from) + .unwrap_or_else(|_| { + let kind = block + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + ToolCallContent::from(format!( + "[MCP {kind} content]\n{}", + serde_json::to_string_pretty(block).unwrap_or_else(|_| block.to_string()) + )) + }) }) .collect() } @@ -96,6 +157,15 @@ fn permission_tool_content( "edit" => permission_edit_diff(&prompt.raw_input, cwd) .into_iter() .collect(), + "apply_patch" => crate::tools::patch::preview_patch(&prompt.raw_input, cwd) + .unwrap_or_default() + .into_iter() + .map(|change| { + agent_client_protocol::schema::v1::Diff::new(change.path, change.new_text) + .old_text(change.old_text) + .into() + }) + .collect(), _ => Vec::new(), } } @@ -345,19 +415,19 @@ fn acp_question_answers( .map(|field| { let mut answers = Vec::new(); match content.get(&field.selection) { - Some(ElicitationContentValue::String(value)) => { + Some(ElicitationContentValue::String(value)) if !field.multiple => { if let Some(label) = field.labels.get(value) { answers.push(serde_json::Value::String(label.clone())); } } - Some(ElicitationContentValue::StringArray(values)) => { - answers.extend(values.iter().filter_map(|value| { - field - .labels - .get(value) - .cloned() - .map(serde_json::Value::String) - })); + Some(ElicitationContentValue::StringArray(values)) if field.multiple => { + for value in values { + if let Some(label) = field.labels.get(value) { + if !answers.iter().any(|answer| answer.as_str() == Some(label)) { + answers.push(serde_json::Value::String(label.clone())); + } + } + } } _ => {} } @@ -367,7 +437,9 @@ fn acp_question_answers( if !field.multiple { answers.clear(); } - answers.push(serde_json::Value::String(custom.to_string())); + answers.push(serde_json::Value::String( + custom.chars().take(8_192).collect(), + )); } } serde_json::Value::Array(answers) @@ -431,19 +503,15 @@ fn available_commands(session: &AcpSession) -> Vec { .merged_config .commands .iter() - .filter(|command| command.name != "compact") + .filter(|command| !matches!(command.name.as_str(), "compact" | "skills" | "mcp")) .map(|command| { let description = command .description .clone() .unwrap_or_else(|| format!("Run /{}", command.name)); - let mut available = AvailableCommand::new(command.name.clone(), description); - if command.template.contains("$ARGUMENTS") { - available = available.input(AvailableCommandInput::Unstructured( - UnstructuredCommandInput::new("Arguments"), - )); - } - available + AvailableCommand::new(command.name.clone(), description).input( + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("Arguments")), + ) }) .collect(); commands.extend( @@ -451,7 +519,7 @@ fn available_commands(session: &AcpSession) -> Vec { .skills .all() .into_iter() - .filter(|skill| skill.name != "compact") + .filter(|skill| !matches!(skill.name.as_str(), "compact" | "skills" | "mcp")) .map(|skill| { AvailableCommand::new( skill.name.clone(), @@ -482,36 +550,32 @@ fn available_commands(session: &AcpSession) -> Vec { commands } -async fn expand_slash_command(session: &AcpSession, prompt: &str) -> Result { +#[derive(Debug, PartialEq, Eq)] +enum SlashExpansion { + Prompt { + prompt: String, + agent: Option, + model: Option, + }, + LocalResult(String), +} + +async fn expand_slash_command(session: &AcpSession, prompt: &str) -> Result { let Some(command_line) = prompt.strip_prefix('/') else { - return Ok(prompt.to_string()); + return Ok(SlashExpansion::Prompt { + prompt: prompt.to_string(), + agent: None, + model: None, + }); }; let (name, args) = command_line .split_once(char::is_whitespace) .map(|(name, args)| (name, args.trim_start())) .unwrap_or((command_line, "")); - if let Some(command) = session - .config - .merged_config - .commands - .iter() - .find(|command| command.name == name) - { - return command - .render(args) - .await - .map(|rendered| rendered.prompt) - .map_err(|_| internal_error()); - } - if let Some(skill) = session.skills.get(name) { - let mut expanded = skill.content.clone(); + if name == "skills" { if !args.is_empty() { - expanded.push_str("\n\nUser task/context:\n"); - expanded.push_str(args); + return Err(Error::invalid_params().data("Usage: /skills")); } - return Ok(expanded); - } - if name == "skills" { let skills = session .skills .all() @@ -524,38 +588,72 @@ async fn expand_slash_command(session: &AcpSession, prompt: &str) -> Result>(); - return Ok(if skills.is_empty() { + return Ok(SlashExpansion::LocalResult(if skills.is_empty() { "No skills are available in this workspace.".to_string() } else { - format!("Available workspace skills:\n{}", skills.join("\n")) - }); + format!("Available skills:\n{}", skills.join("\n")) + })); } if name == "mcp" { - let servers = session - .config - .merged_config - .mcp - .iter() - .map(|(name, server)| { + if !args.is_empty() { + return Err(Error::invalid_params().data("Usage: /mcp")); + } + let manager = crate::mcp::McpManager::ensure( + session.config.merged_config.mcp.clone(), + session.cwd.clone(), + ); + let servers = manager + .lock() + .await + .views() + .into_iter() + .map(|server| { + let detail = server + .detail + .map(|detail| format!(": {detail}")) + .unwrap_or_default(); format!( - "- {} ({}, {})", - name, - server.kind(), - if server.enabled() { - "enabled" - } else { - "disabled" - } + "- {} ({}, {}){}", + server.name, server.kind, server.status, detail ) }) .collect::>(); - return Ok(if servers.is_empty() { + return Ok(SlashExpansion::LocalResult(if servers.is_empty() { "No MCP servers are configured for this workspace.".to_string() } else { - format!("Configured MCP servers:\n{}", servers.join("\n")) + format!("MCP servers:\n{}", servers.join("\n")) + })); + } + if let Some(command) = session + .config + .merged_config + .commands + .iter() + .find(|command| command.name == name) + { + return command + .render(args) + .await + .map_err(|_| internal_error()) + .map(|rendered| SlashExpansion::Prompt { + prompt: rendered.prompt, + agent: rendered.agent, + model: rendered.model, + }); + } + if let Some(skill) = session.skills.get(name) { + let mut expanded = skill.content.clone(); + if !args.is_empty() { + expanded.push_str("\n\nUser task/context:\n"); + expanded.push_str(args); + } + return Ok(SlashExpansion::Prompt { + prompt: expanded, + agent: None, + model: None, }); } - Ok(prompt.to_string()) + Err(Error::invalid_params().data(format!("Unknown ACP command: /{name}"))) } fn merge_acp_mcp_servers(config: &mut LoadedConfig, servers: Vec) { @@ -757,13 +855,17 @@ impl AcpService { .config_options(session_config_options(&session))) } - pub async fn list_sessions(&self, cwd: Option) -> Result { + pub async fn list_sessions( + &self, + cwd: Option, + cursor: Option, + ) -> Result { let cwd = cwd.as_deref().map(workspace_path).transpose()?; let manager = self.session_manager.lock().map_err(|_| internal_error())?; let mut sessions = manager .list_sessions() .into_iter() - .filter(|session| session.parent_id.is_none() && session.archived_at.is_none()) + .filter(|session| session.archived_at.is_none()) .filter(|session| { cwd.as_ref() .is_none_or(|cwd| session.workspace_path == cwd.to_string_lossy()) @@ -771,17 +873,19 @@ impl AcpService { .collect::>(); sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + let (offset, end, next_cursor) = session_page(cursor.as_deref(), sessions.len())?; Ok(ListSessionsResponse::new( sessions .into_iter() - .take(100) + .skip(offset) + .take(end.saturating_sub(offset)) .map(|session| { - SessionInfo::new(session.id, session.workspace_path) - .title(session.title) - .updated_at(system_time_to_iso8601(session.updated_at)) + let root_id = manager.root_session_id_for(&session.id); + acp_session_info(session, root_id) }) .collect(), - )) + ) + .next_cursor(next_cursor)) } pub async fn load_session( @@ -816,10 +920,14 @@ impl AcpService { let (source, messages) = self.attach_persisted_session(&session_id, cwd).await?; let fork_id = { let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + let source_title = manager + .get_session(&session_id) + .map(|session| session.title.clone()) + .unwrap_or_else(|| session_id.clone()); manager .switch_current_workspace_path(&source.cwd.to_string_lossy()) .map_err(|_| internal_error())?; - let fork_id = manager.create_session(Some(format!("{} (fork)", session_id))); + let fork_id = manager.create_session(Some(format!("{source_title} (fork)"))); let messages = match crate::persistence::attachments::clone_messages(&messages, &fork_id) { Ok(messages) => messages, @@ -854,6 +962,16 @@ impl AcpService { } } + pub async fn delete_session(&self, session_id: &str) -> Result<(), Error> { + self.close_session(session_id).await; + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + match manager.try_delete_session(session_id) { + Ok(true) => Ok(()), + Ok(false) => Err(Error::invalid_params().data("unknown session")), + Err(_) => Err(internal_error()), + } + } + pub async fn cancel_session(&self, session_id: &str) { if let Some(session) = self.sessions.lock().await.get(session_id) { if let Some(cancellation) = &session.cancellation { @@ -1004,25 +1122,14 @@ impl AcpService { prompt: Vec, connection: ConnectionTo, ) -> Result { - let session = self + let mut session = self .sessions .lock() .await .get(&session_id) .cloned() .ok_or_else(|| Error::invalid_params().data("unknown session"))?; - let supports_images = session - .models - .iter() - .find(|model| model.provider_id == session.provider && model.id == session.model) - .is_some_and(|model| model.attachment); - let compact_text = prompt - .iter() - .filter_map(|part| match part { - ContentBlock::Text(content) => Some(content.text.as_str()), - _ => None, - }) - .collect::(); + let compact_text = command_text(&prompt); if compact_command(&compact_text)? { if prompt .iter() @@ -1032,19 +1139,76 @@ impl AcpService { } return self.compact_session(&session_id, session, connection).await; } + let expansion = if compact_text.trim_start().starts_with('/') { + Some(expand_slash_command(&session, &compact_text).await?) + } else { + None + }; + let expanded_prompt = match expansion { + Some(SlashExpansion::LocalResult(text)) => { + if prompt + .iter() + .any(|part| !matches!(part, ContentBlock::Text(_))) + { + return Err(Error::invalid_params() + .data("local ACP commands do not accept attachments")); + } + let message_id = cuid2::create_id(); + send_replay_text(&connection, &session_id, &message_id, &text, false, false)?; + return Ok(PromptResponse::new(StopReason::EndTurn)); + } + Some(SlashExpansion::Prompt { + prompt, + agent, + model, + }) => { + if let Some(agent) = agent { + if session + .config + .merged_config + .agent_registry + .get(&agent) + .is_none() + { + return Err(Error::invalid_params() + .data(format!("custom command references unknown agent: {agent}"))); + } + session.agent = agent; + } + if let Some(model_ref) = model { + let (provider, model) = crate::app::parse_model_ref(&model_ref); + let canonical = format!("{provider}/{model}"); + let model = find_selectable_model(&session.models, &canonical)?; + session.provider.clone_from(&model.provider_id); + session.model.clone_from(&model.id); + session.reasoning = resolved_reasoning(&session, session.reasoning_selection); + session.context_window = + model_context_window(&session.config, &session.provider, &session.model); + } + Some(prompt) + } + None => None, + }; + let supports_images = session + .models + .iter() + .find(|model| model.provider_id == session.provider && model.id == session.model) + .is_some_and(|model| model.attachment); let supports_audio = model_supports_audio(&session.config, &session.provider, &session.model); - let (prompt, local_image_paths, local_audio_paths) = prompt_content( + let (mut prompt, local_image_paths, local_audio_paths) = prompt_content( prompt, supports_images, supports_audio, &session_id, &session, )?; + if let Some(expanded_prompt) = expanded_prompt { + prompt = expanded_prompt; + } let mut managed_paths = local_image_paths.clone(); managed_paths.extend(local_audio_paths.clone()); let mut attachment_guard = ManagedAttachmentGuard::new(managed_paths); - let prompt = expand_slash_command(&session, &prompt).await?; if prompt.trim().is_empty() { return Err(Error::invalid_params().data("prompt must include text content")); } @@ -1128,6 +1292,7 @@ impl AcpService { &session, base_context_tokens, (base_cost > 0.0).then_some(base_cost), + None, )?; let stream_session_id = session_id.clone(); @@ -1216,6 +1381,7 @@ impl AcpService { &session, base_context_tokens.saturating_add(token_count), cost.map(|turn_cost| base_cost + turn_cost), + usage, )?; } crate::llm::ChunkMessage::Cancelled => cancelled = true, @@ -1488,6 +1654,7 @@ fn compacted_messages( fn acp_stop_reason(reason: Option) -> StopReason { match reason { Some(crate::llm::TurnStopReason::MaxTokens) => StopReason::MaxTokens, + Some(crate::llm::TurnStopReason::MaxTurnRequests) => StopReason::MaxTurnRequests, Some(crate::llm::TurnStopReason::Refusal) => StopReason::Refusal, None => StopReason::EndTurn, } @@ -2128,7 +2295,19 @@ async fn bridge_terminal_session( .args(vec!["-c".to_string(), start.command.clone()]) .cwd(cwd) .output_byte_limit(crate::tools::terminal_session::MAX_TRANSCRIPT_BYTES as u64); - let terminal_id = match connection.send_request(create).block_task().await { + let create_request = connection.send_request(create).block_task(); + tokio::pin!(create_request); + let terminal_id = match tokio::select! { + _ = cancellation.cancelled() => { + let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalResult( + crate::tools::terminal_session::external_terminal_result( + &start, "", false, None, true, + ), + )); + return; + } + response = &mut create_request => response, + } { Ok(response) => response.terminal_id, Err(error) => { let _ = control_tx.send(crate::tools::TerminalSessionControl::ExternalError( @@ -2271,19 +2450,42 @@ fn send_usage( session: &AcpSession, used: usize, cost: Option, + usage: Option, ) -> Result<(), Error> { - let Some(size) = session.context_window else { - return Ok(()); - }; - let update = SessionUpdate::UsageUpdate( - UsageUpdate::new(used as u64, size as u64) - .cost(cost.map(|amount| AcpCost::new(amount, "USD"))), - ); + let update = SessionUpdate::UsageUpdate(usage_update(session, used, cost, usage)); connection .send_notification(SessionNotification::new(session_id.to_string(), update)) .map_err(|_| internal_error()) } +fn usage_update( + session: &AcpSession, + used: usize, + cost: Option, + usage: Option, +) -> UsageUpdate { + let size = session + .context_window + .map(u64::from) + .unwrap_or_else(|| (used as u64).max(1)); + let mut meta = serde_json::Map::new(); + meta.insert( + "crabcode".to_string(), + serde_json::json!({ + "contextWindowKnown": session.context_window.is_some(), + "usage": usage.map(|usage| serde_json::json!({ + "inputTokens": usage.input_tokens, + "outputTokens": usage.output_tokens, + "cacheReadTokens": usage.cache_read_tokens, + "cacheWriteTokens": usage.cache_write_tokens, + })), + }), + ); + UsageUpdate::new(used as u64, size) + .cost(cost.map(|amount| AcpCost::new(amount, "USD"))) + .meta(meta) +} + fn replay_messages( connection: &ConnectionTo, session_id: &str, @@ -2542,12 +2744,63 @@ mod tests { #[test] fn acp_usage_update_includes_cumulative_usd_cost() { - let update = UsageUpdate::new(1_000, 200_000).cost(AcpCost::new(0.125, "USD")); + let usage = crate::aisdk::chunk::LanguageModelUsage { + input_tokens: 800, + output_tokens: 200, + cache_read_tokens: 500, + cache_write_tokens: 100, + }; + let update = usage_update(&test_session(), 1_000, Some(0.125), Some(usage)); assert_eq!(update.cost.as_ref().map(|cost| cost.amount), Some(0.125)); assert_eq!( update.cost.as_ref().map(|cost| cost.currency.as_str()), Some("USD") ); + assert_eq!(update.size, 1_000); + let meta = update.meta.expect("usage metadata"); + assert_eq!(meta["crabcode"]["contextWindowKnown"], false); + assert_eq!(meta["crabcode"]["usage"]["inputTokens"], 800); + assert_eq!(meta["crabcode"]["usage"]["cacheWriteTokens"], 100); + } + + #[test] + fn session_info_includes_hierarchy_metadata() { + let now = std::time::SystemTime::now(); + let info = acp_session_info( + crate::session::manager::SessionInfo { + id: "child".to_string(), + parent_id: Some("parent".to_string()), + title: "Child".to_string(), + created_at: now, + updated_at: now, + message_count: 0, + workspace_id: 1, + workspace_path: "/tmp".to_string(), + workspace_name: "tmp".to_string(), + workspace_sort_order: 0, + status: crate::session::types::SessionStatus::Idle, + pinned_at: None, + archived_at: None, + }, + Some("root".to_string()), + ); + let meta = info.meta.expect("session metadata"); + assert_eq!(meta["crabcode"]["parentSessionId"], "parent"); + assert_eq!(meta["crabcode"]["rootSessionId"], "root"); + } + + #[test] + fn session_list_cursor_pages_all_sessions() { + assert_eq!( + session_page(None, 250).unwrap(), + (0, 100, Some("100".to_string())) + ); + assert_eq!( + session_page(Some("100"), 250).unwrap(), + (100, 200, Some("200".to_string())) + ); + assert_eq!(session_page(Some("200"), 250).unwrap(), (200, 250, None)); + assert!(session_page(Some("invalid"), 250).is_err()); } #[test] @@ -2707,6 +2960,41 @@ mod tests { assert_eq!(diff.new_text, "fn new() {}\n"); } + #[test] + fn acp_permission_apply_patch_includes_preflight_diff() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("note.txt"); + std::fs::write(&path, "before\n").unwrap(); + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let prompt = crate::tools::PermissionPrompt { + tool_call_id: Some("call_patch".to_string()), + tool_id: "apply_patch".to_string(), + action: crate::tools::PermissionAction::Write, + permission: "edit".to_string(), + patterns: vec![path.to_string_lossy().into_owned()], + target: Some(path.to_string_lossy().into_owned()), + command: None, + workdir: None, + workspace: dir.path().to_string_lossy().into_owned(), + reason: "approval".to_string(), + raw_input: serde_json::json!({ + "patch": format!( + "*** Begin Patch\n*** Update File: {}\n@@\n-before\n+after\n*** End Patch\n", + path.display() + ) + }), + response_tx, + }; + + let content = permission_tool_content(&prompt, dir.path()); + let ToolCallContent::Diff(diff) = &content[0] else { + panic!("expected apply_patch preflight diff"); + }; + assert_eq!(diff.path, path); + assert_eq!(diff.old_text.as_deref(), Some("before\n")); + assert_eq!(diff.new_text, "after\n"); + } + #[test] fn acp_question_form_preserves_single_multi_custom_and_scope() { let form = acp_question_form( @@ -2830,6 +3118,14 @@ mod tests { acp_stop_reason(Some(crate::llm::TurnStopReason::Refusal)), StopReason::Refusal ); + assert_eq!( + acp_stop_reason(Some(crate::llm::TurnStopReason::MaxTurnRequests)), + StopReason::MaxTurnRequests + ); + assert_eq!( + acp_stop_reason(Some(crate::llm::TurnStopReason::MaxTurnRequests)), + StopReason::MaxTurnRequests + ); assert_eq!(acp_stop_reason(None), StopReason::EndTurn); } @@ -3108,14 +3404,39 @@ mod tests { )); } + #[test] + fn acp_tool_result_restores_mcp_audio_and_preserves_unknown_blocks() { + let metadata = serde_json::json!({ + "mcp_result": { + "content": [ + { "type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav" }, + { "type": "future_media", "payload": { "value": 1 } } + ] + } + }); + let content = mcp_native_tool_content(&metadata); + assert!(matches!( + &content[0], + ToolCallContent::Content(content) + if matches!(&content.content, ContentBlock::Audio(audio) + if audio.data == "YXVkaW8=" && audio.mime_type == "audio/wav") + )); + assert!(matches!( + &content[1], + ToolCallContent::Content(content) + if matches!(&content.content, ContentBlock::Text(text) + if text.text.contains("future_media") && text.text.contains("payload")) + )); + } + #[tokio::test] async fn expands_custom_slash_command_before_prompting() { let config = config_with_command(crate::command::custom::CustomCommand { name: "review".to_string(), description: None, template: "Review this carefully: $ARGUMENTS".to_string(), - agent: None, - model: None, + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), subtask: Some(false), source: crate::command::custom::CustomCommandSource::Config(PathBuf::from( "/tmp/opencode.jsonc", @@ -3128,7 +3449,14 @@ mod tests { .await .expect("expanded command"); - assert_eq!(prompt, "Review this carefully: src/acp/service.rs"); + assert_eq!( + prompt, + SlashExpansion::Prompt { + prompt: "Review this carefully: src/acp/service.rs".to_string(), + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), + } + ); } #[tokio::test] @@ -3163,6 +3491,9 @@ mod tests { let prompt = expand_slash_command(&session, "/reviewer src/lib.rs") .await .expect("expanded skill"); + let SlashExpansion::Prompt { prompt, .. } = prompt else { + panic!("expected skill prompt"); + }; assert!(prompt.contains("Inspect correctness and risks.")); assert!(prompt.contains("src/lib.rs")); } @@ -3198,7 +3529,10 @@ mod tests { let prompt = expand_slash_command(&session, "/mcp") .await .expect("mcp status"); - assert!(prompt.contains("filesystem (local, enabled)")); + let SlashExpansion::LocalResult(prompt) = prompt else { + panic!("expected local MCP result"); + }; + assert!(prompt.contains("filesystem (local, connecting)")); } #[test] diff --git a/src/aisdk/chunk.rs b/src/aisdk/chunk.rs index c1b3b7c1..faaf430e 100644 --- a/src/aisdk/chunk.rs +++ b/src/aisdk/chunk.rs @@ -122,6 +122,7 @@ impl FinishReason { "tool_calls" | "function_call" => Self::ToolCalls, "length" => Self::Length, "content_filter" => Self::ContentFilter, + "refusal" => Self::Refusal, other => Self::Unknown(other.to_string()), } } @@ -160,3 +161,16 @@ impl FinishReason { matches!(self, Self::Stop | Self::StopSequence) } } + +#[cfg(test)] +mod tests { + use super::FinishReason; + + #[test] + fn compatible_refusal_is_typed() { + assert_eq!( + FinishReason::from_openai_compatible("refusal"), + FinishReason::Refusal + ); + } +} diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index dae2ce17..a0415c04 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -446,8 +446,14 @@ fn debug_log(msg: &str) { /// Looks for `prompt_tokens_details.cached_tokens` and Anthropic-style fields /// that some gateways forward. fn openai_compatible_usage(usage: &serde_json::Value) -> Option { - let prompt = usage.get("prompt_tokens").and_then(|v| v.as_u64()); - let completion = usage.get("completion_tokens").and_then(|v| v.as_u64()); + let prompt = usage + .get("prompt_tokens") + .or_else(|| usage.get("input_tokens")) + .and_then(|v| v.as_u64()); + let completion = usage + .get("completion_tokens") + .or_else(|| usage.get("output_tokens")) + .and_then(|v| v.as_u64()); let cached = usage .pointer("/prompt_tokens_details/cached_tokens") .and_then(|v| v.as_u64()) @@ -459,6 +465,7 @@ fn openai_compatible_usage(usage: &serde_json::Value) -> Option ChunkType { .and_then(|response| response.get("incomplete_details")) .and_then(|details| details.get("reason")) .and_then(serde_json::Value::as_str); - if matches!(reason, Some("max_output_tokens" | "max_tokens")) { - ChunkType::End { + match reason { + Some("max_output_tokens" | "max_tokens") => ChunkType::End { reason: Some(crate::chunk::FinishReason::Length), - } - } else { - ChunkType::RetryableFailure(RetryError::from_message(responses_incomplete_message( + }, + Some("content_filter" | "refusal" | "safety") => ChunkType::End { + reason: Some(crate::chunk::FinishReason::Refusal), + }, + _ => ChunkType::RetryableFailure(RetryError::from_message(responses_incomplete_message( value, - ))) + ))), } } @@ -2513,6 +2515,22 @@ mod tests { )); } + #[test] + fn response_incomplete_safety_reasons_emit_refusal() { + for reason in ["refusal", "content_filter", "safety"] { + let chunk = response_sse_data_to_chunk(&format!( + r#"{{"type":"response.incomplete","response":{{"incomplete_details":{{"reason":"{reason}"}}}}}}"# + )) + .expect("expected incomplete chunk"); + assert!(matches!( + chunk, + Ok(ChunkType::End { + reason: Some(crate::chunk::FinishReason::Refusal) + }) + )); + } + } + #[test] fn response_completed_retains_provider_usage() { let chunk = response_sse_data_to_chunk( diff --git a/src/llm/client.rs b/src/llm/client.rs index 9c81fd7c..3bd8722c 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -50,13 +50,20 @@ struct ProviderRequestConfig { gateway_caching_auto: bool, } +fn messages_have_user_audio(messages: &[crate::session::types::Message]) -> bool { + messages.iter().any(|message| { + message.role == crate::session::types::MessageRole::User + && !message.local_audio_paths.is_empty() + }) +} + fn provider_kind_for_model( provider_name: &str, npm_package: &str, - supports_audio_input: bool, + has_audio_input: bool, ) -> ProviderKind { let kind = ProviderKind::from_provider(provider_name, npm_package); - if supports_audio_input && kind == ProviderKind::OpenAI { + if has_audio_input && kind == ProviderKind::OpenAI { ProviderKind::OpenAICompatible } else { kind @@ -92,6 +99,7 @@ fn usage_cost( fn turn_stop_reason(stop_reason: Option<&StopReason>) -> Option { match stop_reason { Some(StopReason::MaxTokens) => Some(crate::llm::TurnStopReason::MaxTokens), + Some(StopReason::Hook) => Some(crate::llm::TurnStopReason::MaxTurnRequests), Some(StopReason::Refusal) => Some(crate::llm::TurnStopReason::Refusal), _ => None, } @@ -657,8 +665,14 @@ pub async fn stream_llm_with_cancellation( messages.len() ); let ui_model = model.clone(); - let request_config = - prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; + let request_config = prepare_request_config( + &provider_name, + model, + reasoning_effort, + messages_have_user_audio(&messages), + &sender, + ) + .await?; let mut request_config = request_config; let model_mismatch_warning = ui_vs_request_model_mismatch_warning(&ui_model, &request_config.model_name); @@ -949,7 +963,7 @@ pub async fn build_subagent_llm_session( sender: &crate::llm::ChunkSender, ) -> Result { let request_config = - prepare_request_config(provider_name, model, reasoning_effort, sender).await?; + prepare_request_config(provider_name, model, reasoning_effort, false, sender).await?; Ok(crate::agent::config::LlmSessionConfig { provider_name: request_config.provider_name, model: request_config.model_name, @@ -987,8 +1001,14 @@ pub async fn summarize_for_compaction( } let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); - let request_config = - prepare_request_config(&provider_name, model, reasoning_effort, &warning_sender).await?; + let request_config = prepare_request_config( + &provider_name, + model, + reasoning_effort, + false, + &warning_sender, + ) + .await?; let messages = vec![AisdkMessage::user(prompt)]; let mut response = stream_provider_request( &request_config, @@ -1061,7 +1081,7 @@ pub async fn generate_session_title( ) -> Result { let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); let request_config = - prepare_request_config(&provider_name, model, None, &warning_sender).await?; + prepare_request_config(&provider_name, model, None, false, &warning_sender).await?; let prompt = format!( "Generate a concise chat title for this user request.\n\nRules:\n- Return only the title, no quotes or punctuation wrapper.\n- 3 to 7 words.\n- Use title case only when natural.\n- Do not end with a period.\n\nUser request:\n{}", user_message.trim() @@ -1135,6 +1155,7 @@ async fn prepare_request_config( provider_name: &str, model: String, reasoning_effort: Option, + has_audio_input: bool, sender: &crate::llm::ChunkSender, ) -> Result { let auth_dao = crate::persistence::AuthDAO::new()?; @@ -1157,12 +1178,21 @@ async fn prepare_request_config( let supports_image_input = model_supports_image_input(&model, provider.models.get(&model)); let supports_audio_input = model_supports_audio_input(provider.models.get(&model)); + if has_audio_input + && matches!( + auth_config, + Some(crate::persistence::AuthConfig::OAuth { .. }) + ) + && matches!(provider_name, "openai" | "xai") + { + return Err(anyhow::anyhow!( + "Audio input for {provider_name} requires API-key Chat Completions transport; the configured OAuth Responses transport does not support audio input" + ) + .into()); + } let model_route = resolve_model_route(&provider, model); - let provider_kind = provider_kind_for_model( - provider_name, - &model_route.npm_package, - supports_audio_input, - ); + let provider_kind = + provider_kind_for_model(provider_name, &model_route.npm_package, has_audio_input); let base_url = if provider_name == "xai" && model_route.api.trim().is_empty() { // models.dev currently ships empty api for xAI; default to the public endpoint. "https://api.x.ai".to_string() @@ -3972,6 +4002,10 @@ fn maps_runtime_stop_reasons_to_turn_events() { turn_stop_reason(Some(&StopReason::Refusal)), Some(crate::llm::TurnStopReason::Refusal) ); + assert_eq!( + turn_stop_reason(Some(&StopReason::Hook)), + Some(crate::llm::TurnStopReason::MaxTurnRequests) + ); assert_eq!(turn_stop_reason(Some(&StopReason::Finish)), None); } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index f9dec843..26f43d1f 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -63,6 +63,7 @@ pub enum ChunkMessage { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TurnStopReason { MaxTokens, + MaxTurnRequests, Refusal, } diff --git a/src/persistence/attachments.rs b/src/persistence/attachments.rs index 4645e3dd..d433c150 100644 --- a/src/persistence/attachments.rs +++ b/src/persistence/attachments.rs @@ -9,6 +9,96 @@ pub fn root_dir() -> PathBuf { } } +pub struct AttachmentMigration { + pub messages: Vec, + pub created: Vec, +} + +pub fn migrate_messages( + messages: &[crate::session::types::Message], + session_id: &str, +) -> Result> { + let mut migrated = messages.to_vec(); + let mut created = Vec::new(); + let mut changed = false; + + for message in &mut migrated { + for paths in [ + &mut message.local_image_paths, + &mut message.local_audio_paths, + ] { + let mut unique = Vec::new(); + for attachment_path in std::mem::take(paths) { + if unique.contains(&attachment_path) { + changed = true; + continue; + } + let source = PathBuf::from(&attachment_path); + if is_managed_for_session(&source, session_id) { + unique.push(attachment_path); + continue; + } + let metadata = match std::fs::symlink_metadata(&source) { + Ok(metadata) if metadata.file_type().is_file() => metadata, + _ => { + unique.push(attachment_path); + continue; + } + }; + if metadata.file_type().is_symlink() { + unique.push(attachment_path); + continue; + } + let Some(extension) = source.extension().and_then(|extension| extension.to_str()) + else { + unique.push(attachment_path); + continue; + }; + let data = match std::fs::read(&source) { + Ok(data) => data, + Err(_) => { + unique.push(attachment_path); + continue; + } + }; + match write(session_id, extension, &data) { + Ok(path) => { + unique.push(path.to_string_lossy().into_owned()); + created.push(path); + changed = true; + } + Err(error) => { + for path in created { + remove_file(&path); + } + return Err(error); + } + } + } + *paths = unique; + } + } + + if changed { + Ok(Some(AttachmentMigration { + messages: migrated, + created, + })) + } else { + Ok(None) + } +} + +fn is_managed_for_session(path: &Path, session_id: &str) -> bool { + session_dir(session_id).is_ok_and(|dir| { + path.strip_prefix(dir).is_ok_and(|relative| { + relative.components().count() == 1 + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + }) + }) +} fn validate_session_id(session_id: &str) -> Result<()> { let path = Path::new(session_id); if session_id.is_empty() @@ -163,4 +253,70 @@ mod tests { let traversal = root_dir().join("session").join("..").join("outside.png"); assert!(!is_managed(&traversal)); } + + #[test] + fn legacy_image_and_audio_paths_migrate_once() { + let legacy = tempfile::tempdir().unwrap(); + let image = legacy.path().join("image.png"); + let audio = legacy.path().join("audio.wav"); + std::fs::write(&image, b"image").unwrap(); + std::fs::write(&audio, b"audio").unwrap(); + let session = format!("attachment-migrate-{}", cuid2::create_id()); + let mut message = crate::session::types::Message::user("attachments"); + message.local_image_paths = vec![image.to_string_lossy().into_owned()]; + message.local_audio_paths = vec![audio.to_string_lossy().into_owned()]; + + let migration = migrate_messages(&[message], &session) + .unwrap() + .expect("legacy migration"); + assert_eq!(migration.created.len(), 2); + assert!(migration.messages[0] + .local_image_paths + .iter() + .all(|path| is_managed_for_session(Path::new(path), &session))); + assert!(migration.messages[0] + .local_audio_paths + .iter() + .all(|path| is_managed_for_session(Path::new(path), &session))); + assert!(migrate_messages(&migration.messages, &session) + .unwrap() + .is_none()); + + cleanup_session(&session).unwrap(); + } + + #[test] + fn missing_legacy_paths_remain_loadable() { + let session = format!("attachment-missing-{}", cuid2::create_id()); + let mut message = crate::session::types::Message::user("missing"); + message.local_image_paths = vec!["/definitely/missing/image.png".to_string()]; + + assert!(migrate_messages(&[message], &session).unwrap().is_none()); + } + + #[test] + fn fork_clones_mixed_managed_attachments() { + let source_session = format!("attachment-mixed-source-{}", cuid2::create_id()); + let destination_session = format!("attachment-mixed-dest-{}", cuid2::create_id()); + let image = write(&source_session, "png", b"image").unwrap(); + let audio = write(&source_session, "wav", b"audio").unwrap(); + let mut message = crate::session::types::Message::user("mixed"); + message.local_image_paths = vec![image.to_string_lossy().into_owned()]; + message.local_audio_paths = vec![audio.to_string_lossy().into_owned()]; + + let cloned = clone_messages(&[message], &destination_session).unwrap(); + assert_eq!( + std::fs::read(&cloned[0].local_image_paths[0]).unwrap(), + b"image" + ); + assert_eq!( + std::fs::read(&cloned[0].local_audio_paths[0]).unwrap(), + b"audio" + ); + + cleanup_session(&source_session).unwrap(); + assert!(Path::new(&cloned[0].local_image_paths[0]).exists()); + assert!(Path::new(&cloned[0].local_audio_paths[0]).exists()); + cleanup_session(&destination_session).unwrap(); + } } diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 86030abd..7c39edc1 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -18,16 +18,11 @@ impl From for Message { data: serde_json::json!({ "text": msg.content }), }); } - for path in &msg.local_audio_paths { - parts.push(PersistenceMessagePart { - part_type: "local_audio".to_string(), - data: serde_json::json!({ "path": path }), - }); - } parts } else { msg.parts .into_iter() + .filter(|part| part.part_type != "local_image" && part.part_type != "local_audio") .map(|part| PersistenceMessagePart { part_type: part.part_type, data: part.data, @@ -135,7 +130,12 @@ impl TryFrom for SessionMessage { .flatten() }) .map(str::to_string) - .collect(); + .fold(Vec::new(), |mut paths, path| { + if !paths.contains(&path) { + paths.push(path); + } + paths + }); let content = session_parts .iter() @@ -172,7 +172,12 @@ impl TryFrom for SessionMessage { } }) .map(|path| path.to_string()) - .collect(); + .fold(Vec::new(), |mut paths, path| { + if !paths.contains(&path) { + paths.push(path); + } + paths + }); let compaction_stats = session_parts .iter() @@ -317,7 +322,53 @@ mod tests { let mut session_message = SessionMessage::user("listen"); session_message.local_audio_paths = vec!["/tmp/audio.wav".to_string()]; - let restored = SessionMessage::try_from(Message::from(session_message)).unwrap(); + let persisted = Message::from(session_message); + assert_eq!( + persisted + .parts + .iter() + .filter(|part| part.part_type == "local_audio") + .count(), + 1 + ); + let restored = SessionMessage::try_from(persisted).unwrap(); + assert_eq!(restored.local_audio_paths, vec!["/tmp/audio.wav"]); + } + + #[test] + fn duplicate_legacy_attachment_parts_are_deduplicated() { + let message = Message { + id: "message".to_string(), + session_id: 1, + role: "user".to_string(), + parts: vec![ + PersistenceMessagePart { + part_type: "local_audio".to_string(), + data: serde_json::json!({ "path": "/tmp/audio.wav" }), + }, + PersistenceMessagePart { + part_type: "local_audio".to_string(), + data: serde_json::json!({ "path": "/tmp/audio.wav" }), + }, + ], + timestamp: 0, + tokens_used: 0, + model: None, + provider: None, + agent_mode: None, + duration_ms: 0, + t0_ms: None, + t1_ms: None, + tn_ms: None, + output_tokens: None, + input_tokens: None, + cache_read_tokens: None, + cache_write_tokens: None, + cost: None, + usage_authoritative: false, + }; + + let restored = SessionMessage::try_from(message).unwrap(); assert_eq!(restored.local_audio_paths, vec!["/tmp/audio.wav"]); } diff --git a/src/session/manager.rs b/src/session/manager.rs index 54f45782..cc106918 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -247,6 +247,28 @@ impl SessionManager { let _ = dao.replace_messages(db_id, &persistence_messages); } + if let Some(migration) = + crate::persistence::attachments::migrate_messages(&hydrated.messages, id)? + { + let persistence_messages: Vec = migration + .messages + .iter() + .cloned() + .map(|message| { + let mut db_message: crate::persistence::Message = message.into(); + db_message.session_id = db_id; + db_message + }) + .collect(); + if let Err(error) = dao.replace_messages(db_id, &persistence_messages) { + for path in migration.created { + crate::persistence::attachments::remove_file(&path); + } + return Err(SessionError::PersistenceError(error.to_string())); + } + hydrated.messages = migration.messages; + } + *existing = hydrated; self.message_counts .insert(id.to_string(), existing.messages.len()); @@ -758,19 +780,15 @@ impl SessionManager { session_id: &str, messages: Vec, ) -> Result<(), SessionError> { - if let Some(session) = self.sessions.get_mut(session_id) { - session.messages = messages.clone(); - session.updated_at = SystemTime::now(); - self.message_counts - .insert(session_id.to_string(), session.messages.len()); - } else { + if !self.sessions.contains_key(session_id) { return Err(SessionError::NotFound(session_id.to_string())); } if let Some(ref dao) = self.history_dao { if let Some(db_id) = self.id_mapping.get(session_id) { let persistence_messages: Vec = messages - .into_iter() + .iter() + .cloned() .map(|message| { let mut db_message: crate::persistence::Message = message.into(); db_message.session_id = *db_id; @@ -783,6 +801,15 @@ impl SessionManager { } } + let session = self + .sessions + .get_mut(session_id) + .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?; + session.messages = messages; + session.updated_at = SystemTime::now(); + self.message_counts + .insert(session_id.to_string(), session.messages.len()); + Ok(()) } @@ -948,9 +975,17 @@ impl SessionManager { } pub fn delete_session(&mut self, id: &str) -> bool { + self.try_delete_session(id).unwrap_or_else(|error| { + crate::emit_log!("Failed to delete session {}: {:?}", id, error); + false + }) + } + + pub fn try_delete_session(&mut self, id: &str) -> Result { if let Some(db_id) = self.id_mapping.get(id) { if let Some(ref dao) = self.history_dao { - let _ = dao.delete_session(*db_id); + dao.delete_session(*db_id) + .map_err(|error| SessionError::PersistenceError(error.to_string()))?; } } @@ -975,9 +1010,9 @@ impl SessionManager { if let Err(error) = crate::persistence::attachments::cleanup_session(id) { crate::emit_log!("Failed to clean session attachments for {}: {}", id, error); } - true + Ok(true) } else { - false + Ok(false) } } } diff --git a/src/tools/patch.rs b/src/tools/patch.rs index b03844a5..337694f8 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -5,7 +5,7 @@ use crate::tools::{ }; use async_trait::async_trait; use serde_json::Value; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; pub struct ApplyPatchTool; @@ -172,6 +172,259 @@ pub(crate) fn patch_paths_as_pathbufs(params: &Value, workdir: &Path) -> Vec, + pub new_text: String, +} + +#[derive(Default)] +struct PatchPreviewState { + original: HashMap>, + current: HashMap>, + order: Vec, +} + +impl PatchPreviewState { + fn resolve(workdir: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + workdir.join(path) + } + } + + fn load(&mut self, path: &Path) -> Result, ToolError> { + if let Some(content) = self.current.get(path) { + return Ok(content.clone()); + } + let content = match std::fs::read(path) { + Ok(bytes) => Some(decode_utf8(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(ToolError::Execution(format!( + "Failed to read {}: {error}", + path.display() + ))); + } + }; + self.original.insert(path.to_path_buf(), content.clone()); + self.current.insert(path.to_path_buf(), content.clone()); + self.order.push(path.to_path_buf()); + Ok(content) + } + + fn required(&mut self, path: &Path) -> Result { + self.load(path)?.ok_or_else(|| { + ToolError::NotFound(format!("Patch source file not found: {}", path.display())) + }) + } + + fn create(&mut self, path: PathBuf, content: String) -> Result<(), ToolError> { + if self.load(&path)?.is_some() { + return Err(ToolError::Execution(format!( + "Refusing to overwrite existing file: {}", + path.display() + ))); + } + self.current.insert(path, Some(content)); + Ok(()) + } + + fn write(&mut self, path: PathBuf, content: String) -> Result<(), ToolError> { + self.required(&path)?; + self.current.insert(path, Some(content)); + Ok(()) + } + + fn delete(&mut self, path: PathBuf) -> Result<(), ToolError> { + self.required(&path)?; + self.current.insert(path, None); + Ok(()) + } + + fn finish(self) -> Vec { + self.order + .into_iter() + .filter_map(|path| { + let old_text = self.original.get(&path).cloned().flatten(); + let new_text = self.current.get(&path).cloned().flatten(); + (old_text != new_text).then_some(PatchPreview { + path, + old_text, + new_text: new_text.unwrap_or_default(), + }) + }) + .collect() + } +} + +pub(crate) fn preview_patch( + params: &Value, + workdir: &Path, +) -> Result, ToolError> { + let patch = get_string_param(params, "patch") + .ok_or_else(|| ToolError::Validation("patch is required".to_string()))?; + let patch = clean_patch_input(&patch); + let mut state = PatchPreviewState::default(); + if patch.trim_start().starts_with("*** Begin Patch") { + preview_codex_patch(&patch, workdir, &mut state)?; + } else { + preview_unified_patch(&patch, workdir, &mut state)?; + } + let changes = state.finish(); + if changes.is_empty() { + return Err(ToolError::Validation( + "Patch did not contain any file changes".to_string(), + )); + } + Ok(changes) +} + +fn preview_unified_patch( + patch: &str, + workdir: &Path, + state: &mut PatchPreviewState, +) -> Result<(), ToolError> { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + while index < lines.len() { + if !lines[index].starts_with("--- ") { + index += 1; + continue; + } + let old_path = normalize_diff_path(lines[index].trim_start_matches("--- ")); + index += 1; + if index >= lines.len() || !lines[index].starts_with("+++ ") { + return Err(ToolError::Validation( + "Unified diff file header must include a +++ path".to_string(), + )); + } + let new_path = normalize_diff_path(lines[index].trim_start_matches("+++ ")); + index += 1; + let source = + (old_path != "/dev/null").then(|| PatchPreviewState::resolve(workdir, &old_path)); + let target = + (new_path != "/dev/null").then(|| PatchPreviewState::resolve(workdir, &new_path)); + let mut content = match &source { + Some(path) => state.required(path)?, + None => String::new(), + }; + while index < lines.len() + && !lines[index].starts_with("--- ") + && !lines[index].starts_with("diff --git ") + { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + index = next_index; + } + match (source, target) { + (None, Some(target)) => state.create(target, content)?, + (Some(source), None) => state.delete(source)?, + (Some(source), Some(target)) if source == target => state.write(source, content)?, + (Some(source), Some(target)) => { + state.create(target, content)?; + state.delete(source)?; + } + (None, None) => { + return Err(ToolError::Validation( + "Patch cannot use /dev/null for both paths".to_string(), + )); + } + } + } + Ok(()) +} + +fn preview_codex_patch( + patch: &str, + workdir: &Path, + state: &mut PatchPreviewState, +) -> Result<(), ToolError> { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + if lines.get(index).map(|line| line.trim()) != Some("*** Begin Patch") { + return Err(ToolError::Validation( + "Codex patch must start with *** Begin Patch".to_string(), + )); + } + index += 1; + while index < lines.len() { + let line = lines[index].trim(); + if line == "*** End Patch" { + break; + } + if let Some(path) = line.strip_prefix("*** Add File: ") { + index += 1; + let mut file_lines = Vec::new(); + while index < lines.len() && !lines[index].starts_with("*** ") { + let Some(content) = lines[index].strip_prefix('+') else { + return Err(ToolError::Validation( + "Add File lines must start with +".to_string(), + )); + }; + file_lines.push(content.to_string()); + index += 1; + } + state.create( + PatchPreviewState::resolve(workdir, path), + join_hunk_lines(&file_lines), + )?; + continue; + } + if let Some(path) = line.strip_prefix("*** Delete File: ") { + state.delete(PatchPreviewState::resolve(workdir, path))?; + index += 1; + continue; + } + if let Some(path) = line.strip_prefix("*** Update File: ") { + let source = PatchPreviewState::resolve(workdir, path); + let mut content = state.required(&source)?; + index += 1; + let move_to = lines + .get(index) + .and_then(|line| line.trim().strip_prefix("*** Move to: ")) + .map(str::to_string); + if move_to.is_some() { + index += 1; + } + while index < lines.len() && !lines[index].starts_with("*** ") { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + index = next_index; + } + if let Some(target) = move_to { + let target = PatchPreviewState::resolve(workdir, &target); + if target == source { + state.write(source, content)?; + } else { + state.create(target, content)?; + state.delete(source)?; + } + } else { + state.write(source, content)?; + } + continue; + } + return Err(ToolError::Validation(format!( + "Unsupported patch directive: {line}" + ))); + } + Ok(()) +} + fn clean_patch_input(raw: &str) -> String { let trimmed = raw.trim(); let mut lines: Vec<&str> = trimmed.lines().collect(); @@ -745,6 +998,48 @@ mod tests { assert_eq!(changes[0]["new_text"], "one\nthree\n"); } + #[test] + fn preview_patch_builds_full_file_changes_without_mutating_files() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.txt"); + let deleted = dir.path().join("deleted.txt"); + std::fs::write(&source, "one\ntwo\n").unwrap(); + std::fs::write(&deleted, "remove me\n").unwrap(); + let patch = format!( + "*** Begin Patch\n*** Update File: {}\n*** Move to: moved.txt\n@@\n one\n-two\n+three\n*** Delete File: {}\n*** Add File: added.txt\n+new file\n*** End Patch\n", + source.display(), + deleted.display() + ); + + let changes = preview_patch(&serde_json::json!({ "patch": patch }), dir.path()).unwrap(); + + assert_eq!(changes.len(), 4); + assert!(changes.iter().any(|change| { + change.path == source + && change.old_text.as_deref() == Some("one\ntwo\n") + && change.new_text.is_empty() + })); + assert!(changes.iter().any(|change| { + change.path == dir.path().join("moved.txt") + && change.old_text.is_none() + && change.new_text == "one\nthree\n" + })); + assert!(changes.iter().any(|change| { + change.path == deleted + && change.old_text.as_deref() == Some("remove me\n") + && change.new_text.is_empty() + })); + assert!(changes.iter().any(|change| { + change.path == dir.path().join("added.txt") + && change.old_text.is_none() + && change.new_text == "new file\n" + })); + assert_eq!(std::fs::read_to_string(&source).unwrap(), "one\ntwo\n"); + assert_eq!(std::fs::read_to_string(&deleted).unwrap(), "remove me\n"); + assert!(!dir.path().join("moved.txt").exists()); + assert!(!dir.path().join("added.txt").exists()); + } + #[tokio::test] async fn apply_patch_supports_codex_patch_format() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/tools/question.rs b/src/tools/question.rs index 3ee45494..89923256 100644 --- a/src/tools/question.rs +++ b/src/tools/question.rs @@ -74,16 +74,75 @@ fn parse_questions_param(params: &Value) -> Result { } }; - match parsed { - Value::Array(_) => Ok(normalize_questions(parsed)), - Value::Object(_) => Ok(normalize_questions(Value::Array(vec![parsed]))), + let normalized = match parsed { + Value::Array(items) if items.is_empty() => { + return Err(ToolError::Validation( + "questions array cannot be empty".to_string(), + )); + } + Value::Array(_) => normalize_questions(parsed), + Value::Object(_) => normalize_questions(Value::Array(vec![parsed])), Value::String(s) if !s.trim().is_empty() => { - Ok(normalize_questions(question_from_plain_text(params, &s))) + normalize_questions(question_from_plain_text(params, &s)) + } + _ => { + return Err(ToolError::Validation( + "questions JSON must decode to an array or object".to_string(), + )); + } + }; + validate_normalized_questions(&normalized)?; + Ok(normalized) +} + +fn validate_normalized_questions(questions: &Value) -> Result<(), ToolError> { + let items = questions + .as_array() + .ok_or_else(|| ToolError::Validation("questions must normalize to an array".to_string()))?; + for (index, item) in items.iter().enumerate() { + let object = item.as_object().ok_or_else(|| { + ToolError::Validation(format!("question {} must be an object", index + 1)) + })?; + let has_prompt = ["question", "header"] + .iter() + .filter_map(|key| object.get(*key).and_then(Value::as_str)) + .any(|value| !value.trim().is_empty()); + if !has_prompt { + return Err(ToolError::Validation(format!( + "question {} must include non-empty question or header text", + index + 1 + ))); + } + let options = object + .get("options") + .and_then(Value::as_array) + .ok_or_else(|| { + ToolError::Validation(format!("question {} options must be an array", index + 1)) + })?; + let mut labels = std::collections::HashSet::new(); + for (option_index, option) in options.iter().enumerate() { + let label = option + .get("label") + .and_then(Value::as_str) + .or_else(|| option.as_str()) + .map(str::trim) + .filter(|label| !label.is_empty()) + .ok_or_else(|| { + ToolError::Validation(format!( + "question {} option {} must include a non-empty label", + index + 1, + option_index + 1 + )) + })?; + if !labels.insert(label.to_string()) { + return Err(ToolError::Validation(format!( + "question {} contains duplicate option label: {label}", + index + 1 + ))); + } } - _ => Err(ToolError::Validation( - "questions JSON must decode to an array or object".to_string(), - )), } + Ok(()) } fn normalize_questions(value: Value) -> Value { @@ -546,6 +605,23 @@ mod tests { assert!(err.contains("questions parameter cannot be empty")); } + #[test] + fn parse_questions_rejects_empty_or_malformed_items() { + for params in [ + json!({ "questions": [] }), + json!({ "questions": [null] }), + json!({ "questions": [{ "question": "", "header": "" }] }), + json!({ + "questions": [{ + "question": "Pick", + "options": [{"label":"A"}, {"label":"A"}] + }] + }), + ] { + assert!(parse_questions_param(¶ms).is_err(), "{params}"); + } + } + #[test] fn model_output_includes_questions_and_answers() { let questions = json!([ From 4dec0596b3d76c517c6981f2c7fe42ec00cb2b97 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 7 Sep 2026 16:40:20 +0700 Subject: [PATCH 19/26] fix(acp): wire slash commands end to end --- _docs/acp.mdx | 4 +- src/acp/service.rs | 150 +++++++++++++++++++---- tests/acp_stdio.rs | 295 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 362 insertions(+), 87 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 72b9203f..0b078fad 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -44,14 +44,14 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | Area | Full behavior | Status | Runtime or protocol requirements | | --- | --- | --- | --- | -| Transport | JSON-RPC over stdio through `crabcode acp`, protocol-only stdout, clean stdin EOF shutdown, and subprocess initialize/response coverage. | Full | The subprocess wrapper must not write banners or logs to stdout. | +| Transport | JSON-RPC over stdio through `crabcode acp`, protocol-only stdout, clean stdin EOF shutdown, and subprocess coverage for initialize, session creation, command advertisement, and command dispatch. | Full | The subprocess wrapper must not write banners or logs to stdout. | | Sessions | Create, cursor-list, load, resume, close, delete, and fork all persisted sessions, including child sessions. Lists include Crabcode parent/root IDs in ACP `_meta`; forks preserve the source title, regenerate message IDs, copy attachments independently, and publish commands for the new session. Delete removes persisted history and managed attachments. | Full | ACP has no standard nested-session tree field, so hierarchy is exposed through the `crabcode` metadata extension while the standard list remains flat. | | Prompts | Text, embedded resources, PNG/JPEG/GIF/WebP images, and WAV/MP3 audio; assistant text and reasoning stream back to the editor. Attachments use private per-session storage, survive load/resume, copy independently on fork, delete with persisted sessions, and readable legacy paths migrate automatically on load. | Full | The selected model route must advertise the matching input modality. Audio uses verified OpenAI-compatible Chat Completions `input_audio`; unsupported provider transports return a clear error instead of dropping media. | | Modes and models | Visible primary agents, selectable model catalog entries, and supported reasoning-effort values are session-local ACP configuration options. | Full | Available reasoning values follow the selected model's catalog capability. | | Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | | Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow, and reject choices. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | | Cancellation | `session/cancel` interrupts model turns, questions, compaction, and terminal creation/execution while keeping the session reusable. Crabcode maps completion, output limit, configured turn limit, refusal/content filtering, and cancellation to ACP `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. | Full | Provider failures that are not normal stop conditions remain JSON-RPC/tool errors, as required by ACP's stop-reason model. | -| Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, and `/compact`. Custom command agent/model overrides apply to that turn. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | +| Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, `/compact`, and `/btw`. Commands are dispatched from regular ACP text prompts as required by the protocol. Custom command agent/model overrides apply to that turn while embedded resources and media remain attached. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context. `/btw` runs a no-tools side question without changing the main transcript. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | | MCP | Project MCP and client-supplied stdio, HTTP, and SSE servers merge into the session. Static headers, structured results, live status, resources, annotations, images, audio, and metadata are preserved. Project-configured remote MCP continues to use Crabcode's OAuth credential flow. | Full | ACP currently advertises only the HTTP/SSE transport flags; its client-server schema has no stdio flag or remote OAuth fields. Client-supplied remote auth can still be provided through headers. | | Terminals | `terminal_session` and terminal-mode `bash` use the editor terminal host through create, embed, wait, output, kill, and release. Output is bounded for the model, and cancellation also covers terminal creation. | Full | The editor must advertise terminal hosting. User input and resize happen directly in the embedded editor terminal because ACP has no agent-issued stdin/resize requests. | | Questions | Agent questions use capability-gated ACP form elicitation with validated non-empty prompts/options, unique labels, ordered single/multi-select answers, custom text, cardinality checks, deduplication, length bounds, cancellation, and safe skip behavior. | Full | Form elicitation is an unstable ACP capability and is only sent to editors that advertise it. | diff --git a/src/acp/service.rs b/src/acp/service.rs index 86b86f1a..3a5b5909 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -31,24 +31,13 @@ pub struct AcpService { } fn command_text(parts: &[ContentBlock]) -> String { - let mut text = String::new(); - for part in parts { - match part { - ContentBlock::Text(content) => text.push_str(&content.text), - ContentBlock::ResourceLink(link) => text.push_str(&format!("[{}]", link.uri)), - ContentBlock::Resource(resource) => match &resource.resource { - EmbeddedResourceResource::TextResourceContents(resource) => { - text.push_str(&format!("[{}]\n{}", resource.uri, resource.text)); - } - EmbeddedResourceResource::BlobResourceContents(resource) => { - text.push_str(&format!("[{}]", resource.uri)); - } - _ => {} - }, - _ => {} - } - } - text + parts + .iter() + .find_map(|part| match part { + ContentBlock::Text(content) => Some(content.text.clone()), + _ => None, + }) + .unwrap_or_default() } fn acp_session_info( @@ -503,7 +492,7 @@ fn available_commands(session: &AcpSession) -> Vec { .merged_config .commands .iter() - .filter(|command| !matches!(command.name.as_str(), "compact" | "skills" | "mcp")) + .filter(|command| !matches!(command.name.as_str(), "btw" | "compact" | "skills" | "mcp")) .map(|command| { let description = command .description @@ -519,7 +508,7 @@ fn available_commands(session: &AcpSession) -> Vec { .skills .all() .into_iter() - .filter(|skill| !matches!(skill.name.as_str(), "compact" | "skills" | "mcp")) + .filter(|skill| !matches!(skill.name.as_str(), "btw" | "compact" | "skills" | "mcp")) .map(|skill| { AvailableCommand::new( skill.name.clone(), @@ -545,6 +534,15 @@ fn available_commands(session: &AcpSession) -> Vec { "compact", "Summarize this session to reduce context", )); + commands.push( + AvailableCommand::new( + "btw", + "Ask a quick side question without changing session history", + ) + .input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new("Question"), + )), + ); commands.sort_by(|left, right| left.name.cmp(&right.name)); commands.dedup_by(|left, right| left.name == right.name); commands @@ -558,6 +556,7 @@ enum SlashExpansion { model: Option, }, LocalResult(String), + Btw(String), } async fn expand_slash_command(session: &AcpSession, prompt: &str) -> Result { @@ -572,6 +571,13 @@ async fn expand_slash_command(session: &AcpSession, prompt: &str) -> Result")); + } + return Ok(SlashExpansion::Btw(question.to_string())); + } if name == "skills" { if !args.is_empty() { return Err(Error::invalid_params().data("Usage: /skills")); @@ -1195,6 +1201,17 @@ impl AcpService { } Some(prompt) } + Some(SlashExpansion::Btw(question)) => { + if prompt + .iter() + .any(|part| !matches!(part, ContentBlock::Text(_))) + { + return Err(Error::invalid_params().data("/btw does not accept attachments")); + } + return self + .btw_session(&session_id, session, question, connection) + .await; + } None => None, }; let supports_images = session @@ -1204,16 +1221,14 @@ impl AcpService { .is_some_and(|model| model.attachment); let supports_audio = model_supports_audio(&session.config, &session.provider, &session.model); - let (mut prompt, local_image_paths, local_audio_paths) = prompt_content( + let (prompt, local_image_paths, local_audio_paths) = prompt_content( prompt, + expanded_prompt.as_deref(), supports_images, supports_audio, &session_id, &session, )?; - if let Some(expanded_prompt) = expanded_prompt { - prompt = expanded_prompt; - } let mut managed_paths = local_image_paths.clone(); managed_paths.extend(local_audio_paths.clone()); let mut attachment_guard = ManagedAttachmentGuard::new(managed_paths); @@ -1556,6 +1571,32 @@ impl AcpService { } } + async fn btw_session( + &self, + session_id: &str, + session: AcpSession, + question: String, + connection: ConnectionTo, + ) -> Result { + let history = self + .session_manager + .lock() + .map_err(|_| internal_error())? + .get_session_ref(session_id) + .map(|stored| stored.messages.clone()) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + let answer = crate::llm::client::generate_btw_answer( + session.provider, + session.model, + question, + history, + ) + .await + .map_err(|error| internal_error_with(&error.to_string()))?; + send_text(&connection, session_id, &cuid2::create_id(), answer, false)?; + Ok(PromptResponse::new(StopReason::EndTurn)) + } + async fn run_compaction( &self, session_id: &str, @@ -1939,17 +1980,21 @@ fn workspace_path(path: &Path) -> Result { fn prompt_content( parts: Vec, + text_override: Option<&str>, supports_images: bool, supports_audio: bool, session_id: &str, session: &AcpSession, ) -> Result<(String, Vec, Vec), Error> { let mut text = String::new(); + let mut text_override = text_override; let mut local_image_paths = Vec::new(); let mut local_audio_paths = Vec::new(); for part in parts { match part { - ContentBlock::Text(content) => text.push_str(&content.text), + ContentBlock::Text(content) => { + text.push_str(text_override.take().unwrap_or(&content.text)); + } ContentBlock::ResourceLink(link) => { text.push_str(&format!("[{}]", link.uri)); } @@ -3224,6 +3269,23 @@ mod tests { assert!(command.input.is_none()); } + #[tokio::test] + async fn advertises_and_parses_btw_side_command() { + let session = test_session(); + let command = available_commands(&session) + .into_iter() + .find(|command| command.name == "btw") + .expect("btw command"); + assert!(command.input.is_some()); + assert_eq!( + expand_slash_command(&session, "/btw what changed?") + .await + .expect("btw expansion"), + SlashExpansion::Btw("what changed?".to_string()) + ); + assert!(expand_slash_command(&session, "/btw").await.is_err()); + } + #[test] fn builds_smaller_soft_compaction_for_acp() { let session = test_session(); @@ -3289,6 +3351,42 @@ mod tests { crate::persistence::attachments::cleanup_session(&session_id).unwrap(); } + #[test] + fn command_text_override_preserves_embedded_context() { + let resource = agent_client_protocol::schema::v1::EmbeddedResource::new( + EmbeddedResourceResource::TextResourceContents( + agent_client_protocol::schema::v1::TextResourceContents::new( + "important context", + "file:///tmp/context.txt", + ), + ), + ); + let (text, images, audio) = prompt_content( + vec![ + ContentBlock::Text(agent_client_protocol::schema::v1::TextContent::new( + "/review src/lib.rs", + )), + ContentBlock::Text(agent_client_protocol::schema::v1::TextContent::new( + "\nAdditional instructions", + )), + ContentBlock::Resource(resource), + ], + Some("expanded command prompt"), + false, + false, + "test-session", + &test_session(), + ) + .expect("prompt content"); + + assert_eq!( + text, + "expanded command prompt\nAdditional instructions[file:///tmp/context.txt]\nimportant context" + ); + assert!(images.is_empty()); + assert!(audio.is_empty()); + } + #[test] fn writes_supported_acp_audio_to_managed_session_storage() { let session_id = format!("acp-audio-{}", cuid2::create_id()); @@ -3307,6 +3405,7 @@ mod tests { vec![ContentBlock::Audio( agent_client_protocol::schema::v1::AudioContent::new("YXVkaW8=", "audio/wav"), )], + None, false, false, &session_id, @@ -3345,6 +3444,7 @@ mod tests { "image/png", )), ], + None, true, false, &session_id, diff --git a/tests/acp_stdio.rs b/tests/acp_stdio.rs index 3e357dad..b0c49e6b 100644 --- a/tests/acp_stdio.rs +++ b/tests/acp_stdio.rs @@ -1,35 +1,107 @@ use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; -use std::sync::mpsc; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; -#[test] -fn initialize_over_stdio_and_shutdown_on_eof() { - let workspace = tempfile::tempdir().expect("workspace"); - let home = tempfile::tempdir().expect("home"); - let config = tempfile::tempdir().expect("config"); - let state = tempfile::tempdir().expect("state"); - let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode")) - .args(["acp", "--cwd"]) - .arg(workspace.path()) - .env("HOME", home.path()) - .env("XDG_CONFIG_HOME", config.path()) - .env("XDG_STATE_HOME", state.path()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn crabcode acp"); - - let stdout = child.stdout.take().expect("stdout"); - let (line_tx, line_rx) = mpsc::channel(); - std::thread::spawn(move || { - let mut line = String::new(); - let result = BufReader::new(stdout).read_line(&mut line).map(|_| line); - let _ = line_tx.send(result); - }); - - let request = serde_json::json!({ +struct AcpProcess { + child: Child, + stdin: Option, + lines: Receiver>, + _home: tempfile::TempDir, + _config: tempfile::TempDir, + _state: tempfile::TempDir, +} + +impl AcpProcess { + fn spawn(workspace: &std::path::Path) -> Self { + let home = tempfile::tempdir().expect("home"); + let config = tempfile::tempdir().expect("config"); + let state = tempfile::tempdir().expect("state"); + let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode")) + .args(["acp", "--cwd"]) + .arg(workspace) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", config.path()) + .env("XDG_STATE_HOME", state.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn crabcode acp"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = child.stdout.take().expect("stdout"); + let (line_tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if line_tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin: Some(stdin), + lines, + _home: home, + _config: config, + _state: state, + } + } + + fn send(&mut self, request: serde_json::Value) { + let stdin = self.stdin.as_mut().expect("open stdin"); + writeln!(stdin, "{request}").expect("write ACP request"); + stdin.flush().expect("flush ACP request"); + } + + fn recv(&mut self) -> serde_json::Value { + let line = match self.lines.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(line)) => line, + Ok(Err(error)) => { + let _ = self.child.kill(); + panic!("failed reading ACP response: {error}"); + } + Err(_) => { + let _ = self.child.kill(); + panic!("timed out waiting for ACP message"); + } + }; + serde_json::from_str(&line).unwrap_or_else(|error| { + let _ = self.child.kill(); + panic!("invalid protocol message {line:?}: {error}"); + }) + } + + fn recv_response(&mut self, id: u64) -> (serde_json::Value, Vec) { + let mut notifications = Vec::new(); + loop { + let message = self.recv(); + if message.get("id").and_then(serde_json::Value::as_u64) == Some(id) { + return (message, notifications); + } + notifications.push(message); + } + } + + fn close_and_wait(mut self) { + self.stdin.take(); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = self.child.try_wait().expect("poll ACP process") { + break status; + } + if Instant::now() >= deadline { + let _ = self.child.kill(); + panic!("ACP process did not shut down after stdin EOF"); + } + std::thread::sleep(Duration::from_millis(20)); + }; + assert!(status.success(), "ACP exited with {status}"); + } +} + +fn initialize(process: &mut AcpProcess) -> serde_json::Value { + process.send(serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -37,26 +109,16 @@ fn initialize_over_stdio_and_shutdown_on_eof() { "protocolVersion": 1, "clientCapabilities": {} } - }); - let mut stdin = child.stdin.take().expect("stdin"); - writeln!(stdin, "{request}").expect("write initialize"); - stdin.flush().expect("flush initialize"); - - let line = match line_rx.recv_timeout(Duration::from_secs(10)) { - Ok(Ok(line)) => line, - Ok(Err(error)) => { - let _ = child.kill(); - panic!("failed reading ACP response: {error}"); - } - Err(_) => { - let _ = child.kill(); - panic!("timed out waiting for ACP initialize response"); - } - }; - let response: serde_json::Value = serde_json::from_str(line.trim()).unwrap_or_else(|error| { - let _ = child.kill(); - panic!("invalid protocol response {line:?}: {error}"); - }); + })); + process.recv_response(1).0 +} + +#[test] +fn initialize_over_stdio_and_shutdown_on_eof() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut process = AcpProcess::spawn(workspace.path()); + let response = initialize(&mut process); + assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 1); assert_eq!(response["result"]["protocolVersion"], 1); @@ -71,17 +133,130 @@ fn initialize_over_stdio_and_shutdown_on_eof() { true ); - drop(stdin); - let deadline = Instant::now() + Duration::from_secs(10); - let status = loop { - if let Some(status) = child.try_wait().expect("poll ACP process") { - break status; + process.close_and_wait(); +} + +#[test] +fn advertises_and_dispatches_commands_over_stdio() { + let workspace = tempfile::tempdir().expect("workspace"); + let command_dir = workspace.path().join(".crabcode/commands"); + std::fs::create_dir_all(&command_dir).expect("command dir"); + std::fs::write( + command_dir.join("wire-probe.md"), + r#"--- +description: Verify ACP custom command rendering +agent: missing-agent +--- +Probe $ARGUMENTS !`printf wired > command-marker` +"#, + ) + .expect("custom command"); + let skill_dir = workspace.path().join(".crabcode/skills/wire-skill"); + std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + r#"--- +name: wire-skill +description: Verify ACP skill discovery +--- +Wire skill instructions. +"#, + ) + .expect("skill"); + let mut process = AcpProcess::spawn(workspace.path()); + initialize(&mut process); + + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": workspace.path(), + "mcpServers": [] } - if Instant::now() >= deadline { - let _ = child.kill(); - panic!("ACP process did not shut down after stdin EOF"); + })); + let (new_session, mut notifications) = process.recv_response(2); + let session_id = new_session["result"]["sessionId"] + .as_str() + .expect("session id") + .to_string(); + while !notifications + .iter() + .any(|message| message["params"]["update"]["sessionUpdate"] == "available_commands_update") + { + notifications.push(process.recv()); + } + let command_update = notifications + .iter() + .find(|message| message["params"]["update"]["sessionUpdate"] == "available_commands_update") + .expect("available commands update"); + let names = command_update["params"]["update"]["availableCommands"] + .as_array() + .expect("commands") + .iter() + .filter_map(|command| command["name"].as_str()) + .collect::>(); + assert!(names.contains(&"btw")); + assert!(names.contains(&"compact")); + assert!(names.contains(&"mcp")); + assert!(names.contains(&"skills")); + assert!(names.contains(&"wire-probe")); + assert!(names.contains(&"wire-skill")); + + for (id, command, expected_text) in [ + (3, "/skills", "wire-skill"), + (4, "/mcp", "No MCP servers are configured"), + ] { + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": command }] + } + })); + let (response, notifications) = process.recv_response(id); + assert_eq!(response["result"]["stopReason"], "end_turn"); + assert!(notifications.iter().any(|message| { + message["params"]["update"]["sessionUpdate"] == "agent_message_chunk" + && message["params"]["update"]["content"]["text"] + .as_str() + .is_some_and(|text| text.contains(expected_text)) + })); + } + + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "/wire-probe custom args" }] } - std::thread::sleep(Duration::from_millis(20)); - }; - assert!(status.success(), "ACP exited with {status}"); + })); + let (response, _) = process.recv_response(5); + assert_eq!(response["error"]["code"], -32602); + assert!(response["error"]["data"] + .as_str() + .is_some_and(|data| data.contains("unknown agent"))); + assert_eq!( + std::fs::read_to_string(workspace.path().join("command-marker")).expect("command marker"), + "wired" + ); + + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": 6, + "method": "session/prompt", + "params": { + "sessionId": session_id, + "prompt": [{ "type": "text", "text": "/compact" }] + } + })); + let (response, _) = process.recv_response(6); + assert_eq!(response["error"]["code"], -32602); + assert_eq!(response["error"]["data"], "Nothing to compact"); + + process.close_and_wait(); } From 310150a67f07b722c3af11d35fe6773446c21c26 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 7 Sep 2026 16:47:15 +0700 Subject: [PATCH 20/26] fix(tui): expose chat-only commands --- src/autocomplete/command.rs | 32 +++++++++++++++++++++----------- src/views/command_palette.rs | 34 ++++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index f7aaaa60..76ed706c 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -141,24 +141,18 @@ impl CommandAuto { let mut results: Vec = Vec::new(); for cmd in &self.commands { - if !is_chat && self.chat_only_commands.contains(&cmd.name) { - continue; - } if cmd.name.to_lowercase().starts_with(&input_lower) { if seen.insert(cmd.name.clone()) { - results.push(cmd.clone()); + results.push(self.with_context_description(cmd, is_chat)); } } } for (token, command_name) in &self.hidden_token_map { - if !is_chat && self.chat_only_commands.contains(command_name) { - continue; - } if token.to_lowercase().starts_with(&input_lower) { if seen.insert(command_name.clone()) { if let Some(cmd) = self.commands.iter().find(|c| c.name == *command_name) { - results.push(cmd.clone()); + results.push(self.with_context_description(cmd, is_chat)); } } } @@ -176,6 +170,14 @@ impl CommandAuto { results } + + fn with_context_description(&self, command: &Suggestion, is_chat: bool) -> Suggestion { + let mut suggestion = command.clone(); + if !is_chat && self.chat_only_commands.contains(&command.name) { + suggestion.description = format!("{} (available during chat)", command.description); + } + suggestion + } } #[cfg(test)] @@ -247,15 +249,23 @@ mod tests { } #[test] - fn test_chat_only_suggestions_hidden_outside_chat() { + fn test_chat_only_suggestions_remain_discoverable_outside_chat() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); let home_suggestions = auto.get_suggestions("c", false); - assert!(home_suggestions.iter().all(|s| s.name != "compact")); + let compact = home_suggestions + .iter() + .find(|suggestion| suggestion.name == "compact") + .expect("compact command should remain discoverable from home"); + assert!(compact.description.contains("available during chat")); let chat_suggestions = auto.get_suggestions("c", true); - assert!(chat_suggestions.iter().any(|s| s.name == "compact")); + let compact = chat_suggestions + .iter() + .find(|suggestion| suggestion.name == "compact") + .expect("compact command should be available during chat"); + assert!(!compact.description.contains("available during chat")); } #[test] diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index ae61e778..82f7c89a 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -338,15 +338,17 @@ fn core_palette_items( let Some(registered) = registry.get(command) else { continue; }; - if !is_chat && registered.chat_only { - continue; - } + let description = if !is_chat && registered.chat_only { + format!("{description} (available during chat)") + } else { + description.to_string() + }; items.push(DialogItem { id: command.to_string(), name: name.to_string(), group: group.to_string(), - description: description.to_string(), + description, tip: command_palette_tip(command), provider_id: registered.hidden_tokens.join(" "), active: false, @@ -623,7 +625,7 @@ mod tests { use std::path::PathBuf; #[test] - fn palette_hides_chat_only_commands_outside_chat() { + fn palette_keeps_chat_only_commands_discoverable_outside_chat() { let mut registry = Registry::new(); register_all_commands(&mut registry); let mut state = init_command_palette(); @@ -632,7 +634,20 @@ mod tests { assert!(state.dialog.items.iter().any(|item| item.id == "models")); assert!(state.dialog.items.iter().any(|item| item.id == "copy")); - assert!(!state.dialog.items.iter().any(|item| item.id == "fork")); + let compact = state + .dialog + .items + .iter() + .find(|item| item.id == "compact") + .expect("compact command should remain discoverable from home"); + assert!(compact.description.contains("available during chat")); + let fork = state + .dialog + .items + .iter() + .find(|item| item.id == "fork") + .expect("fork command should remain discoverable from home"); + assert!(fork.description.contains("available during chat")); assert!(!state .dialog .items @@ -652,6 +667,13 @@ mod tests { assert!(state.dialog.items.iter().any(|item| item.id == "fork")); assert!(state.dialog.items.iter().any(|item| item.id == "move")); assert!(state.dialog.items.iter().any(|item| item.id == "open-find")); + let compact = state + .dialog + .items + .iter() + .find(|item| item.id == "compact") + .expect("compact command should be available during chat"); + assert!(!compact.description.contains("available during chat")); } #[test] From b26111dad644edbee90f99d7f0d52b0eda7098ed Mon Sep 17 00:00:00 2001 From: Yanuar Date: Tue, 8 Sep 2026 15:40:18 +0700 Subject: [PATCH 21/26] feat(compaction): apply OpenCode settings --- _docs/acp.mdx | 2 +- _docs/config/index.mdx | 20 ++ _docs/config/opencode-compatibility.mdx | 2 +- _docs/prompt-caching.mdx | 2 +- crabcode.schema.json | 32 ++- src/acp/service.rs | 127 ++++++++- src/agent/config.rs | 2 + src/agent/subagent.rs | 46 +++- src/aisdk/README.md | 1 + src/aisdk/mod.rs | 3 +- src/aisdk/response.rs | 329 ++++++++++-------------- src/app.rs | 84 +++++- src/config/configuration.rs | 50 ++++ src/llm/client.rs | 25 +- src/main.rs | 2 + src/model/discovery.rs | 25 ++ src/session/compaction.rs | 83 ++++++ src/tools/task.rs | 3 + 18 files changed, 621 insertions(+), 217 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 0b078fad..544cd029 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -51,7 +51,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | | Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow, and reject choices. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | | Cancellation | `session/cancel` interrupts model turns, questions, compaction, and terminal creation/execution while keeping the session reusable. Crabcode maps completion, output limit, configured turn limit, refusal/content filtering, and cancellation to ACP `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. | Full | Provider failures that are not normal stop conditions remain JSON-RPC/tool errors, as required by ACP's stop-reason model. | -| Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, `/compact`, and `/btw`. Commands are dispatched from regular ACP text prompts as required by the protocol. Custom command agent/model overrides apply to that turn while embedded resources and media remain attached. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context. `/btw` runs a no-tools side question without changing the main transcript. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | +| Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, `/compact`, and `/btw`. Commands are dispatched from regular ACP text prompts as required by the protocol. Custom command agent/model overrides apply to that turn while embedded resources and media remain attached. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context, while OpenCode-compatible `compaction.auto`, `compaction.prune`, and `compaction.reserved` also apply to normal ACP turns. `/btw` runs a no-tools side question without changing the main transcript. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | | MCP | Project MCP and client-supplied stdio, HTTP, and SSE servers merge into the session. Static headers, structured results, live status, resources, annotations, images, audio, and metadata are preserved. Project-configured remote MCP continues to use Crabcode's OAuth credential flow. | Full | ACP currently advertises only the HTTP/SSE transport flags; its client-server schema has no stdio flag or remote OAuth fields. Client-supplied remote auth can still be provided through headers. | | Terminals | `terminal_session` and terminal-mode `bash` use the editor terminal host through create, embed, wait, output, kill, and release. Output is bounded for the model, and cancellation also covers terminal creation. | Full | The editor must advertise terminal hosting. User input and resize happen directly in the embedded editor terminal because ACP has no agent-issued stdin/resize requests. | | Questions | Agent questions use capability-gated ACP form elicitation with validated non-empty prompts/options, unique labels, ordered single/multi-select answers, custom text, cardinality checks, deduplication, length bounds, cancellation, and safe skip behavior. | Full | Form elicitation is an unstable ACP capability and is only sent to editors that advertise it. | diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx index cd1cf675..9b0a34b1 100644 --- a/_docs/config/index.mdx +++ b/_docs/config/index.mdx @@ -86,6 +86,26 @@ Use `tui.compactMode` to explicitly control compact mode and its sticky message `compactMode` takes priority over the preference saved by `/compact-mode`. Without a config value, crabcode restores the last `/compact-mode` choice; new installations default to enabled. `compact_mode` is accepted as an alias. +## Context compaction + +Crabcode applies the OpenCode-compatible `compaction` settings to persisted TUI and ACP sessions. Request-time pruning is also inherited by print mode and subagents: + +```jsonc title="opencode.jsonc" +{ + "compaction": { + "auto": true, + "prune": false, + "reserved": 10000 + } +} +``` + +- `auto` defaults to `true`. TUI and ACP sessions check the effective model context window before a request and after a completed turn, then summarize persisted context when the usable window is reached. +- `prune` defaults to `false`. When enabled, stale tool outputs may be shortened or cleared only in the provider-facing request; persisted session history and rendered tool cards remain intact. +- `reserved` is an optional token buffer subtracted from the context window. Without an explicit value, Crabcode reserves the smaller of the model output limit and 20,000 tokens. + +Set `compaction` to `false` to disable automatic compaction and request-time tool-output pruning. Manual `/compact` remains available. + ## Permissions crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index c5aeff5e..bb1155b3 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -44,7 +44,7 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. | | `instructions` | ✅ | ✅ | Loads the listed files relative to the project root (or from absolute and `~/` paths) and appends their contents to the system prompt. Unreadable files produce a config warning. | | `tools` | ✅ | ✅ | Global tool enable/disable map. Disabled tools are removed before agent-specific tool policies are applied. | -| `compaction` | ✅ | | Accepted and parsed (`false` or `{ "auto", "prune" }`), but compaction behavior is not applied yet. | +| `compaction` | ✅ | ✅ | Applies `auto` and `reserved` to persisted TUI/ACP sessions; `prune` also applies to print and subagent requests. Manual `/compact` remains available when auto-compaction is disabled. | | `watcher` | ✅ | ✅ | Controls file suggestions in command autocomplete. Use `false` to disable them or `{ "ignore": ["path"] }` to exclude paths. | | `formatter` | ✅ | | Accepted and parsed by file extension, but configured formatter commands are not run yet. | | `disabled_providers` | ✅ | ✅ | Removes the listed provider IDs from model discovery and selection. | diff --git a/_docs/prompt-caching.mdx b/_docs/prompt-caching.mdx index b742737d..860260ec 100644 --- a/_docs/prompt-caching.mdx +++ b/_docs/prompt-caching.mdx @@ -63,7 +63,7 @@ Grok Build’s sampler sends that header so cli-chat-proxy emits `response.doom_ Doom-loop recovery matches Grok Build’s sampler, not a client tool-name babysitter. Only confident `tail_repetition:{n}@thinking` from `response.doom_loop_check` resamples: skip that generation’s tools, inject `RECOVERY_REMINDER` into the **next request only** (same `` user-role item as `ConversationItem::system_reminder` — not a visible user turn). After `DEFAULT_MAX_RETRIES` (2) the abort is disarmed and the turn continues. Repeating reads or shell commands are not a loop — they usually mean earlier tool results were cleared. -Tool-result pruning matches Grok Build `prune_conversation` (`.devrefs/references/xai-org/grok-build/crates/codegen/xai-chat-state/src/actor/request_builder.rs`): **only when estimated tokens exceed 50% of a 500k window** (`should_prune`), last 3 user turns stay intact, older large results soft-trim at 4000/1500/1500, age ≥ 10 hard-clear. Those numbers have not changed in Grok Build; the important gate is not pruning a short session at all. +Tool-result pruning follows the OpenCode `compaction.prune` contract and is disabled by default. When enabled, the latest two user turns and roughly 40,000 tokens of newer tool output stay intact; older candidates are cleared only when doing so reclaims more than 20,000 tokens. `skill` results are always protected. This mutates only the provider-facing request, not persisted history or rendered tool cards. **Parent-cached aux** (e.g. max-steps text-only summary): keeps parent `prompt_cache_key` + session/conv so the conversation prefix can reuse the main turn's KV cache; assigns a fresh `aux-…` req id. diff --git a/crabcode.schema.json b/crabcode.schema.json index c26ecd63..22322984 100644 --- a/crabcode.schema.json +++ b/crabcode.schema.json @@ -21,6 +21,34 @@ ], "type": "object" }, + "CompactionConfigFile": { + "anyOf": [ + { + "type": "boolean" + }, + { + "additionalProperties": false, + "properties": { + "auto": { + "default": true, + "description": "Automatically compact when estimated context reaches the usable window.", + "type": "boolean" + }, + "prune": { + "default": false, + "description": "Remove stale tool outputs from provider requests while preserving durable history.", + "type": "boolean" + }, + "reserved": { + "description": "Token buffer reserved before the context limit for compaction and model output.", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + ] + }, "ImageOpenWith": { "anyOf": [ { @@ -351,7 +379,9 @@ ] }, "command": true, - "compaction": true, + "compaction": { + "$ref": "#/$defs/CompactionConfigFile" + }, "default_agent": true, "disabled_providers": true, "enabled_providers": true, diff --git a/src/acp/service.rs b/src/acp/service.rs index 3a5b5909..eee280b5 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,25 @@ pub struct AcpService { client_capabilities: Arc>, } +fn model_output_limit(config: &LoadedConfig, provider_id: &str, model_id: &str) -> Option { + if let Some(limit) = config + .merged_config + .custom_providers + .get(&provider_id.trim().to_ascii_lowercase()) + .and_then(|provider| provider.models.get(model_id)) + .and_then(|model| model.max_tokens) + { + return Some(limit); + } + crate::model::discovery::Discovery::new_with_config( + Some(config.merged_config.custom_providers.clone()), + config.merged_config.disabled_providers.clone(), + config.merged_config.enabled_providers.clone(), + ) + .ok() + .and_then(|discovery| discovery.get_model_output_limit(provider_id, model_id)) +} + fn command_text(parts: &[ContentBlock]) -> String { parts .iter() @@ -1260,6 +1279,29 @@ impl AcpService { user_message.provider = Some(session.provider.clone()); user_message.model = Some(session.model.clone()); user_message.agent_mode = Some(session.agent.clone()); + let auto_compacted = self + .maybe_auto_compact_session( + &session_id, + &session, + cancellation.clone(), + crate::session::compaction::message_context_tokens(&user_message), + ) + .await?; + if cancellation.is_cancelled() { + if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { + current.cancellation = None; + } + return Ok(PromptResponse::new(StopReason::Cancelled)); + } + if auto_compacted { + messages = self + .session_manager + .lock() + .map_err(|_| internal_error())? + .get_session_ref(&session_id) + .map(|stored| stored.messages.clone()) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + } { let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; manager @@ -1342,6 +1384,7 @@ impl AcpService { tool_permissions(&stream_session), stream_session.config.merged_config.websearch.clone(), stream_session.config.merged_config.mcp.clone(), + stream_session.config.merged_config.compaction.clone(), stream_session.cwd.to_string_lossy().to_string(), Some(stream_tool_registry), messages, @@ -1491,16 +1534,24 @@ impl AcpService { .set_session_status(&session_id, status, failed.as_deref()) .map_err(|_| internal_error())?; } - if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { - current.cancellation = None; - } - if assistant.was_interrupted { + if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { + current.cancellation = None; + } return Ok(PromptResponse::new(StopReason::Cancelled)); } if let Some(error) = failed { + if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { + current.cancellation = None; + } return Err(internal_error_with(&error)); } + let _ = self + .maybe_auto_compact_session(&session_id, &session, cancellation.clone(), 0) + .await?; + if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { + current.cancellation = None; + } Ok(PromptResponse::new(acp_stop_reason(turn_stop_reason))) } @@ -1538,7 +1589,7 @@ impl AcpService { } let result = self - .run_compaction(session_id, &session, cancellation.clone()) + .run_compaction(session_id, &session, cancellation.clone(), 0) .await; if let Some(current) = self.sessions.lock().await.get_mut(session_id) { current.cancellation = None; @@ -1602,6 +1653,7 @@ impl AcpService { session_id: &str, session: &AcpSession, cancellation: CancellationToken, + minimum_tokens: usize, ) -> Result { let messages = { let manager = self.session_manager.lock().map_err(|_| internal_error())?; @@ -1613,7 +1665,7 @@ impl AcpService { let selection = crate::session::compaction::select_messages_for_compaction_with_min( &messages, crate::session::compaction::DEFAULT_TAIL_TURNS, - 0, + minimum_tokens, ) .ok_or_else(|| Error::invalid_params().data("Nothing to compact"))?; let before_tokens = crate::session::compaction::total_context_tokens(&messages); @@ -1646,6 +1698,69 @@ impl AcpService { .map_err(|_| internal_error())?; Ok(stats) } + + async fn maybe_auto_compact_session( + &self, + session_id: &str, + session: &AcpSession, + cancellation: CancellationToken, + additional_tokens: usize, + ) -> Result { + let messages = self + .session_manager + .lock() + .map_err(|_| internal_error())? + .get_session_ref(session_id) + .map(|stored| stored.messages.clone()) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + let used_tokens = crate::session::compaction::total_context_tokens(&messages) + .saturating_add(additional_tokens); + if !crate::session::compaction::should_auto_compact( + &session.config.merged_config.compaction, + used_tokens, + session.context_window, + model_output_limit(&session.config, &session.provider, &session.model), + ) { + return Ok(false); + } + if crate::session::compaction::select_messages_for_compaction( + &messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + ) + .is_none() + { + return Ok(false); + } + self.session_manager + .lock() + .map_err(|_| internal_error())? + .set_session_status( + session_id, + crate::session::types::SessionStatus::Streaming, + None, + ) + .map_err(|_| internal_error())?; + let result = self + .run_compaction( + session_id, + session, + cancellation, + crate::session::compaction::MIN_COMPACTABLE_TOKENS, + ) + .await; + self.session_manager + .lock() + .map_err(|_| internal_error())? + .set_session_status(session_id, crate::session::types::SessionStatus::Idle, None) + .map_err(|_| internal_error())?; + match result { + Ok(_) => Ok(true), + Err(error) => { + crate::emit_log!("ACP auto-compaction skipped after failure: {:?}", error); + Ok(false) + } + } + } } fn compaction_reasoning(session: &AcpSession) -> Option { diff --git a/src/agent/config.rs b/src/agent/config.rs index 1a4f60f6..a8e1a1b4 100644 --- a/src/agent/config.rs +++ b/src/agent/config.rs @@ -92,6 +92,7 @@ pub struct LlmSessionConfig { pub prompt_cache_key: Option, /// Vercel AI Gateway: `providerOptions.gateway.caching = "auto"`. pub gateway_caching_auto: bool, + pub prune_tool_outputs: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -142,6 +143,7 @@ mod tests { openai_options: OpenAIRequestOptions::default(), prompt_cache_key: None, gateway_caching_auto: false, + prune_tool_outputs: false, } } diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 75c9bc4a..cc1a584e 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -381,7 +381,7 @@ async fn start_subagent_stream( headers: std::collections::HashMap, cancel_token: Option, ) -> Result { - use crate::aisdk::core::response::stream_with_tools; + use crate::aisdk::core::response::{stream_with_tools_options, StreamWithToolsOptions}; use crate::aisdk::{Anthropic, OpenAI, OpenAICompatible}; match session.provider_kind { @@ -406,7 +406,7 @@ async fn start_subagent_stream( .build() .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -414,6 +414,9 @@ async fn start_subagent_stream( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: session.prune_tool_outputs, + }, ) .await .map_err(|e| format!("Stream error: {}", e)) @@ -431,7 +434,7 @@ async fn start_subagent_stream( .build() .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -439,6 +442,9 @@ async fn start_subagent_stream( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: session.prune_tool_outputs, + }, ) .await .map_err(|e| format!("Stream error: {}", e)) @@ -485,7 +491,7 @@ async fn start_subagent_stream( .build() .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -493,6 +499,9 @@ async fn start_subagent_stream( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: session.prune_tool_outputs, + }, ) .await .map_err(|e| format!("Stream error: {}", e)) @@ -534,14 +543,17 @@ async fn resolve_subagent_session( let (fallback_sender, _fallback_rx) = tokio::sync::mpsc::unbounded_channel(); let sender = sender.unwrap_or(&fallback_sender); - crate::llm::client::build_subagent_llm_session( + let prune_tool_outputs = parent_session.prune_tool_outputs; + let mut session = crate::llm::client::build_subagent_llm_session( provider, model.to_string(), agent.reasoning_effort, sender, ) .await - .map_err(|err| err.to_string()) + .map_err(|err| err.to_string())?; + session.prune_tool_outputs = prune_tool_outputs; + Ok(session) } fn normalize_subagent_output(output: String) -> String { @@ -595,6 +607,27 @@ mod tests { assert_eq!(session.reasoning_effort, None); } + #[test] + fn subagent_inherits_parent_pruning_policy() { + let mut warnings = Vec::new(); + let agent = crate::agent::definition::parse_agent_definitions_from_config( + Some(&serde_json::json!({ + "explore": { "mode": "subagent" } + })), + &mut warnings, + ) + .pop() + .expect("agent definition"); + let mut parent = test_session(None); + parent.prune_tool_outputs = true; + + let session = tokio_test::block_on(resolve_subagent_session(&agent, parent, None)) + .expect("resolved session"); + + assert!(warnings.is_empty()); + assert!(session.prune_tool_outputs); + } + #[test] fn subagent_model_shorthand_does_not_inherit_parent_reasoning_effort() { let mut warnings = Vec::new(); @@ -706,6 +739,7 @@ mod tests { openai_options: crate::agent::config::OpenAIRequestOptions::default(), prompt_cache_key: None, gateway_caching_auto: false, + prune_tool_outputs: false, } } } diff --git a/src/aisdk/README.md b/src/aisdk/README.md index 78e5b495..0308a516 100644 --- a/src/aisdk/README.md +++ b/src/aisdk/README.md @@ -41,5 +41,6 @@ Done for packaging/host hooks: - Typed terminal stop reasons include normal completion, max tokens, refusal, hooks, and errors - Normalized provider usage events retain input, output, cache-read, and cache-write token accounting across multi-step turns - User messages support typed image and WAV/MP3 audio inputs; audio is serialized through verified Chat Completions `input_audio` content parts +- `stream_with_tools_options` accepts generic request-loop policy such as request-local stale tool-output pruning; the host maps product configuration into those options Keep app glue outside this tree (`src/tools/aisdk_bridge.rs`, `src/llm/*`). diff --git a/src/aisdk/mod.rs b/src/aisdk/mod.rs index 600f7334..30a0b80d 100644 --- a/src/aisdk/mod.rs +++ b/src/aisdk/mod.rs @@ -29,7 +29,8 @@ pub mod core { pub mod response { pub use super::super::response::{ - stream_with_tools, LanguageModelStream, StreamTextResponse, + stream_with_tools_options, LanguageModelStream, StreamTextResponse, + StreamWithToolsOptions, }; } diff --git a/src/aisdk/response.rs b/src/aisdk/response.rs index 8dd41460..5e8debf6 100644 --- a/src/aisdk/response.rs +++ b/src/aisdk/response.rs @@ -23,19 +23,9 @@ const DOOM_LOOP_MAX_RECOVERIES: usize = 2; /// `.devrefs/references/xai-org/grok-build/crates/codegen/xai-grok-sampler/src/doom_loop_recovery.rs` const DOOM_LOOP_REMINDER: &str = "Your messages have been flagged as looping. Your response has been flagged as repeating the same text pattern. Avoid excessive repetition. If you are having trouble ask the user for guidance."; -/// Grok Build `PruningConfig::keep_last_n_turns` (`.devrefs/.../memory.rs`). -/// Never prune tool results from this many most recent **user turns**. -const KEEP_RECENT_USER_TURNS: usize = 3; -/// Grok Build `PruningConfig::hard_clear_age_turns`. -const HARD_CLEAR_AGE_TURNS: usize = 10; -/// Grok Build `should_prune`: only when total tokens > 50% of the context -/// window (`.devrefs/.../request_builder.rs`). grok-4.6 is 500k, so 250k. -/// Estimate tokens as UTF-8 bytes / 4. Under this, every tool result stays. -const PRUNE_AFTER_ESTIMATED_TOKENS: usize = 250_000; -/// Soft-trim threshold for older-but-still-retained tool outputs (chars). -const TOOL_OUTPUT_SOFT_TRIM_CHARS: usize = 4_000; -const TOOL_OUTPUT_SOFT_TRIM_HEAD: usize = 1_500; -const TOOL_OUTPUT_SOFT_TRIM_TAIL: usize = 1_500; +const KEEP_RECENT_USER_TURNS: usize = 2; +const PRUNE_PROTECT_TOKENS: usize = 40_000; +const PRUNE_MINIMUM_TOKENS: usize = 20_000; const PRUNED_TOOL_OUTPUT_PLACEHOLDER: &str = "[Old tool result content cleared]"; /// Image compact hysteresis: @@ -117,6 +107,34 @@ pub async fn stream_with_tools( stop_when: Option, headers: HashMap, cancel_token: Option, +) -> Result { + stream_with_tools_options( + provider, + messages, + tools, + max_steps, + stop_when, + headers, + cancel_token, + StreamWithToolsOptions::default(), + ) + .await +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StreamWithToolsOptions { + pub prune_tool_outputs: bool, +} + +pub async fn stream_with_tools_options( + provider: P, + messages: Vec, + tools: Vec, + max_steps: Option, + stop_when: Option, + headers: HashMap, + cancel_token: Option, + options: StreamWithToolsOptions, ) -> Result { let (mut response, tx) = StreamTextResponse::create(); let _ = tx.send(ChunkType::Start); @@ -161,12 +179,18 @@ pub async fn stream_with_tools( } let step_summary = provider_step_log_summary(¤t_messages, &tools); - let pruned = - maybe_prune_stale_tool_outputs(&mut current_messages, tool_prefix_tokens(&tools)); + let pruned = maybe_prune_stale_tool_outputs_if_enabled( + &mut current_messages, + tool_prefix_tokens(&tools), + options.prune_tool_outputs, + ); if pruned > 0 { let _ = tx_loop.send(ChunkType::Metadata(format!( - "tool_outputs_pruned count={} keep_user_turns={} hard_clear_age={}", - pruned, KEEP_RECENT_USER_TURNS, HARD_CLEAR_AGE_TURNS + "tool_outputs_pruned count={} keep_user_turns={} protect_tokens={} minimum_tokens={}", + pruned, + KEEP_RECENT_USER_TURNS, + PRUNE_PROTECT_TOKENS, + PRUNE_MINIMUM_TOKENS ))); } let images_evicted = compact_images_to_budget_in_place(&mut current_messages); @@ -1055,67 +1079,53 @@ fn rollback_provider_attempt( /// provider request. Durable UI/history copies are left intact — this only /// mutates the request-facing message list. /// -/// Matches Grok Build `prune_conversation` -/// (`.devrefs/references/xai-org/grok-build/crates/codegen/xai-chat-state/src/actor/request_builder.rs`): -/// - Never prune tool results from the last [`KEEP_RECENT_USER_TURNS`] user turns -/// (the current implement turn stays intact no matter how many tools it used) -/// - Soft-trim large older results to head+tail -/// - Hard-clear anything older than [`HARD_CLEAR_AGE_TURNS`] user turns -/// - Drop attached images on pruned outputs (base64 is extremely expensive) -/// -/// Grok Build only runs this when context is already over half full -/// (`should_prune`). See [`maybe_prune_stale_tool_outputs`]. fn prune_stale_tool_outputs_in_place(messages: &mut [Message]) -> usize { - let mut turn_from_end: usize = 0; - let mut seen_first_user = false; - let mut pruned = 0usize; + let mut user_turns = 0usize; + let mut protected_tokens = 0usize; + let mut candidate_tokens = 0usize; + let mut candidates = Vec::new(); for i in (0..messages.len()).rev() { if is_injected_system_reminder(&messages[i]) { continue; } if matches!(&messages[i], Message::User(_)) { - if seen_first_user { - turn_from_end += 1; - } - seen_first_user = true; + user_turns += 1; continue; } - let Message::ToolOutput(output) = &mut messages[i] else { + let Message::ToolOutput(output) = &messages[i] else { continue; }; - - if turn_from_end < KEEP_RECENT_USER_TURNS { + if output.name == "skill" || user_turns < KEEP_RECENT_USER_TURNS { continue; } - - let had_images = !output.images.is_empty(); - let original_len = output.output.len(); - if turn_from_end >= HARD_CLEAR_AGE_TURNS { - if original_len > PRUNED_TOOL_OUTPUT_PLACEHOLDER.len() || had_images { - output.output = PRUNED_TOOL_OUTPUT_PLACEHOLDER.to_string(); - output.images.clear(); - pruned += 1; - } + let image_bytes = output + .images + .iter() + .map(|image| image.data_url.len()) + .sum::(); + let tokens = output.output.len().saturating_add(image_bytes) / 4; + if protected_tokens < PRUNE_PROTECT_TOKENS { + protected_tokens = protected_tokens.saturating_add(tokens); continue; } - - let trimmed = soft_trim_tool_output(&output.output); - if trimmed.len() < original_len || had_images { - output.output = trimmed; + candidate_tokens = candidate_tokens.saturating_add(tokens); + candidates.push(i); + } + if candidate_tokens <= PRUNE_MINIMUM_TOKENS { + return 0; + } + let pruned = candidates.len(); + for index in candidates { + if let Message::ToolOutput(output) = &mut messages[index] { + output.output = PRUNED_TOOL_OUTPUT_PLACEHOLDER.to_string(); output.images.clear(); - pruned += 1; } } - pruned } -fn estimated_input_tokens(messages: &[Message]) -> usize { - (message_log_summary(messages).text_bytes + total_image_bytes(messages)) / 4 -} - fn tool_prefix_tokens(tools: &[Tool]) -> usize { tools .iter() @@ -1129,18 +1139,19 @@ fn tool_prefix_tokens(tools: &[Tool]) -> usize { / 4 } -/// No-op until the transcript is large enough that Grok Build would prune -/// (`total_tokens > context_window / 2`). Hard probing is almost always -/// the model rereading because we already cleared the bytes it needed. -/// `extra_prefix_tokens` is the tools array (built-in + MCP schemas) that -/// rides every request but is not in `messages`. -fn maybe_prune_stale_tool_outputs(messages: &mut [Message], extra_prefix_tokens: usize) -> usize { - if estimated_input_tokens(messages).saturating_add(extra_prefix_tokens) - <= PRUNE_AFTER_ESTIMATED_TOKENS - { +fn maybe_prune_stale_tool_outputs(messages: &mut [Message], _extra_prefix_tokens: usize) -> usize { + prune_stale_tool_outputs_in_place(messages) +} + +fn maybe_prune_stale_tool_outputs_if_enabled( + messages: &mut [Message], + extra_prefix_tokens: usize, + enabled: bool, +) -> usize { + if !enabled { return 0; } - prune_stale_tool_outputs_in_place(messages) + maybe_prune_stale_tool_outputs(messages, extra_prefix_tokens) } /// Evict oldest inline images with hysteresis: @@ -1225,27 +1236,6 @@ fn total_image_bytes(messages: &[Message]) -> usize { .sum() } -fn soft_trim_tool_output(text: &str) -> String { - let char_count = text.chars().count(); - if char_count <= TOOL_OUTPUT_SOFT_TRIM_CHARS { - return text.to_string(); - } - - let head: String = text.chars().take(TOOL_OUTPUT_SOFT_TRIM_HEAD).collect(); - let tail: String = text - .chars() - .rev() - .take(TOOL_OUTPUT_SOFT_TRIM_TAIL) - .collect::() - .chars() - .rev() - .collect(); - let omitted = char_count - .saturating_sub(TOOL_OUTPUT_SOFT_TRIM_HEAD) - .saturating_sub(TOOL_OUTPUT_SOFT_TRIM_TAIL); - format!("{head}\n\n...[{omitted} chars truncated]...\n\n{tail}") -} - #[derive(Debug, Default)] struct MessageLogSummary { system_messages: usize, @@ -1916,10 +1906,10 @@ mod tests { use super::{ compact_images_to_budget_in_place, doom_loop_metadata_is_confident, doom_loop_trigger_is_confident, maybe_prune_stale_tool_outputs, - prune_stale_tool_outputs_in_place, soft_trim_tool_output, stream_with_tools, - total_image_bytes, ToolCallAccumulator, DOOM_LOOP_REMINDER, HARD_CLEAR_AGE_TURNS, + maybe_prune_stale_tool_outputs_if_enabled, prune_stale_tool_outputs_in_place, + stream_with_tools, total_image_bytes, ToolCallAccumulator, DOOM_LOOP_REMINDER, IMAGE_COMPACT_PLACEHOLDER, IMAGE_COMPACT_RECLAIM_TARGET_BYTES, IMAGE_COMPACT_TRIGGER_BYTES, - KEEP_RECENT_USER_TURNS, PRUNED_TOOL_OUTPUT_PLACEHOLDER, TOOL_OUTPUT_SOFT_TRIM_CHARS, + PRUNED_TOOL_OUTPUT_PLACEHOLDER, PRUNE_MINIMUM_TOKENS, PRUNE_PROTECT_TOKENS, }; use crate::chunk::{ChunkType, FinishReason, MessagePhase, ReasoningReplayItem}; use crate::message::Message; @@ -1963,21 +1953,6 @@ mod tests { )); } - #[test] - fn soft_trim_tool_output_keeps_short_text() { - assert_eq!(soft_trim_tool_output("short"), "short"); - } - - #[test] - fn soft_trim_tool_output_keeps_head_and_tail() { - let text = "a".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 500); - let trimmed = soft_trim_tool_output(&text); - assert!(trimmed.len() < text.len()); - assert!(trimmed.starts_with('a')); - assert!(trimmed.ends_with('a')); - assert!(trimmed.contains("chars truncated")); - } - fn tool_output_text<'a>(messages: &'a [Message], call_id: &str) -> &'a str { messages .iter() @@ -1991,81 +1966,56 @@ mod tests { } #[test] - fn prune_stale_tool_outputs_keeps_current_user_turn() { - // A long implement turn (many tool results, one user message) must - // stay intact — this is the grok-build keep_last_n_turns contract. + fn prune_stale_tool_outputs_keeps_two_recent_user_turns() { let mut messages = vec![Message::user("implement")]; - for i in 0..20 { + for i in 0..3 { messages.push(Message::tool_output( format!("call_{i}"), "read", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 200), + "x".repeat(30_000), false, )); } + messages.push(Message::user("follow up")); let pruned = prune_stale_tool_outputs_in_place(&mut messages); assert_eq!(pruned, 0); - for message in &messages { - if let Message::ToolOutput(output) = message { - assert_eq!(output.output.len(), TOOL_OUTPUT_SOFT_TRIM_CHARS + 200); - assert_ne!(output.output, PRUNED_TOOL_OUTPUT_PLACEHOLDER); - } - } } #[test] - fn prune_stale_tool_outputs_soft_trims_outside_keep_window() { - // Grok Build ages a tool as (users after it) - 1, so KEEP+2 user - // turns are needed before the oldest result leaves the keep window. - let last = KEEP_RECENT_USER_TURNS + 1; - let mut messages = Vec::new(); - for i in 0..=last { - messages.push(Message::user(format!("u{i}"))); - messages.push(Message::tool_output( - format!("c{i}"), - "bash", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 200), - false, - )); - } + fn prune_stale_tool_outputs_protects_recent_output_budget() { + let large = "x".repeat(PRUNE_PROTECT_TOKENS * 4); + let mut messages = vec![ + Message::user("old"), + Message::tool_output("old-1", "bash", large.clone(), false), + Message::tool_output("old-2", "bash", large.clone(), false), + Message::user("recent-1"), + Message::user("recent-2"), + ]; let pruned = prune_stale_tool_outputs_in_place(&mut messages); - assert!(pruned > 0); - - let oldest = tool_output_text(&messages, "c0"); - assert_ne!(oldest, PRUNED_TOOL_OUTPUT_PLACEHOLDER); - assert!(oldest.len() < TOOL_OUTPUT_SOFT_TRIM_CHARS + 200); - assert!(oldest.contains("chars truncated")); - - let newest = tool_output_text(&messages, &format!("c{last}")); - assert_eq!(newest.len(), TOOL_OUTPUT_SOFT_TRIM_CHARS + 200); + assert_eq!(pruned, 1); + assert_eq!( + tool_output_text(&messages, "old-1"), + PRUNED_TOOL_OUTPUT_PLACEHOLDER + ); + assert_eq!(tool_output_text(&messages, "old-2"), large); } #[test] - fn prune_stale_tool_outputs_hard_clears_by_user_turn_age() { - let last = HARD_CLEAR_AGE_TURNS + 1; - let mut messages = Vec::new(); - for i in 0..=last { - messages.push(Message::user(format!("u{i}"))); - messages.push(Message::tool_output( - format!("c{i}"), - "bash", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 50), - false, - )); - } - - let pruned = prune_stale_tool_outputs_in_place(&mut messages); - assert!(pruned > 0); - - assert_eq!( - tool_output_text(&messages, "c0"), + fn prune_stale_tool_outputs_requires_minimum_savings() { + let small = "x".repeat(PRUNE_MINIMUM_TOKENS * 4); + let mut messages = vec![ + Message::user("old"), + Message::tool_output("old", "bash", small, false), + Message::user("recent-1"), + Message::user("recent-2"), + ]; + assert_eq!(prune_stale_tool_outputs_in_place(&mut messages), 0); + assert_ne!( + tool_output_text(&messages, "old"), PRUNED_TOOL_OUTPUT_PLACEHOLDER ); - - let newest = tool_output_text(&messages, &format!("c{last}")); - assert_eq!(newest.len(), TOOL_OUTPUT_SOFT_TRIM_CHARS + 50); } #[test] @@ -2075,7 +2025,7 @@ mod tests { messages.push(Message::tool_output( format!("call_{i}"), "read", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 200), + "x".repeat((PRUNE_PROTECT_TOKENS + PRUNE_MINIMUM_TOKENS + 1) * 4), false, )); } @@ -2086,45 +2036,34 @@ mod tests { } #[test] - fn maybe_prune_leaves_small_transcripts_intact() { - // Four user turns of large tool results, but nowhere near 50% of a - // 500k window — Grok Build would not prune, so we must not either. - let mut messages = Vec::new(); - for i in 0..=HARD_CLEAR_AGE_TURNS { - messages.push(Message::user(format!("u{i}"))); - messages.push(Message::tool_output( - format!("c{i}"), - "bash", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 50), - false, - )); - } + fn prune_stale_tool_outputs_never_prunes_skill_results() { + let large = "x".repeat((PRUNE_PROTECT_TOKENS + PRUNE_MINIMUM_TOKENS + 1) * 4); + let mut messages = vec![ + Message::user("old"), + Message::tool_output("skill", "skill", large, false), + Message::user("recent-1"), + Message::user("recent-2"), + ]; assert_eq!(maybe_prune_stale_tool_outputs(&mut messages, 0), 0); - assert_ne!( - tool_output_text(&messages, "c0"), - PRUNED_TOOL_OUTPUT_PLACEHOLDER - ); } #[test] - fn maybe_prune_counts_tool_prefix_tokens() { - let mut messages = Vec::new(); - for i in 0..=HARD_CLEAR_AGE_TURNS { - messages.push(Message::user(format!("u{i}"))); - messages.push(Message::tool_output( - format!("c{i}"), - "bash", - "x".repeat(TOOL_OUTPUT_SOFT_TRIM_CHARS + 50), - false, - )); - } - assert_eq!(maybe_prune_stale_tool_outputs(&mut messages, 0), 0); - let original_len = tool_output_text(&messages, "c0").len(); - assert!( - maybe_prune_stale_tool_outputs(&mut messages, super::PRUNE_AFTER_ESTIMATED_TOKENS + 1) - > 0 + fn disabled_pruning_keeps_provider_request_tool_outputs_intact() { + let large = "x".repeat((PRUNE_PROTECT_TOKENS + PRUNE_MINIMUM_TOKENS + 1) * 4); + let mut messages = vec![ + Message::user("old"), + Message::tool_output("old-1", "bash", large.clone(), false), + Message::tool_output("old-2", "bash", large.clone(), false), + Message::user("recent-1"), + Message::user("recent-2"), + ]; + + assert_eq!( + maybe_prune_stale_tool_outputs_if_enabled(&mut messages, 0, false), + 0 ); - assert!(tool_output_text(&messages, "c0").len() < original_len); + assert_eq!(tool_output_text(&messages, "old-1"), large); + assert_eq!(tool_output_text(&messages, "old-2"), large); } #[test] diff --git a/src/app.rs b/src/app.rs index 994abedf..37047190 100644 --- a/src/app.rs +++ b/src/app.rs @@ -963,6 +963,7 @@ pub struct App { pub editor: crate::config::EditorConfig, pending_editor_suspend: Option, pub websearch: crate::config::configuration::WebsearchConfig, + compaction: crate::config::configuration::CompactionConfig, pub mcp: crate::config::configuration::McpConfig, mcp_manager: Option>>, mcp_summary: crate::views::home::McpSummary, @@ -1225,6 +1226,7 @@ impl App { editor: crate::config::EditorConfig::default(), pending_editor_suspend: None, websearch: crate::config::configuration::WebsearchConfig::default(), + compaction: crate::config::configuration::CompactionConfig::default(), mcp: crate::config::configuration::McpConfig::default(), mcp_manager: None, mcp_summary: crate::views::home::McpSummary::default(), @@ -1462,6 +1464,7 @@ impl App { self.images = loaded_config.merged_config.images.clone(); self.editor = loaded_config.merged_config.editor.clone(); self.websearch = loaded_config.merged_config.websearch.clone(); + self.compaction = loaded_config.merged_config.compaction.clone(); self.mcp = mcp_config; self.config_raw_merged = loaded_config.raw_merged; self.custom_instructions = runtime.custom_instructions; @@ -6520,6 +6523,10 @@ impl App { } fn start_compact_session(&mut self, session_id: &str) { + self.start_compact_session_with_min(session_id, 0); + } + + fn start_compact_session_with_min(&mut self, session_id: &str, minimum_tokens: usize) { if self.compaction_receiver.is_some() { push_toast(Toast::new( "Compaction is already running", @@ -6555,7 +6562,7 @@ impl App { let Some(selection) = crate::session::compaction::select_messages_for_compaction_with_min( &messages, crate::session::compaction::DEFAULT_TAIL_TURNS, - 0, + minimum_tokens, ) else { self.play_sound_event(crate::sound::SoundEvent::Error); push_toast(Toast::new( @@ -10355,6 +10362,9 @@ impl App { } self.cleanup_streaming_for_session(session_id); + if self.maybe_start_auto_compaction(session_id) { + return; + } if self.submit_queued_messages_for_session(session_id) { return; } @@ -10370,6 +10380,59 @@ impl App { self.notify_terminal_event(completion_event); } + fn maybe_start_auto_compaction(&mut self, session_id: &str) -> bool { + if !self.is_active_session(session_id) + || self.compaction_receiver.is_some() + || self.session_has_active_compaction(session_id) + { + return false; + } + if !self.should_auto_compact_current_session(None) { + return false; + } + self.start_compact_session_with_min( + session_id, + crate::session::compaction::MIN_COMPACTABLE_TOKENS, + ); + self.compaction_receiver.is_some() + } + + fn should_auto_compact_current_session( + &self, + pending_message: Option<&crate::session::types::Message>, + ) -> bool { + let mut messages = self.chat_state.chat.messages.clone(); + if let Some(message) = pending_message { + messages.push(message.clone()); + } + let used_tokens = crate::session::compaction::total_context_tokens(&messages) + .saturating_add(self.mcp_tool_prefix_tokens()); + let (context_window, max_output_tokens) = self + .discovery + .as_ref() + .map(|discovery| { + ( + discovery.get_model_limit(&self.provider_name.to_lowercase(), &self.model), + discovery + .get_model_output_limit(&self.provider_name.to_lowercase(), &self.model), + ) + }) + .unwrap_or((None, None)); + if !crate::session::compaction::should_auto_compact( + &self.compaction, + used_tokens, + context_window, + max_output_tokens, + ) { + return false; + } + crate::session::compaction::select_messages_for_compaction( + &self.chat_state.chat.messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + ) + .is_some() + } + fn defer_finish_if_tools_are_running(&mut self, session_id: &str) -> bool { if !self.session_has_running_tool_messages(session_id) { return false; @@ -10953,6 +11016,7 @@ impl App { let agent_registry = self.agent_registry.clone(); let websearch_config = self.websearch.clone(); let mcp_config = self.mcp.clone(); + let compaction_config = self.compaction.clone(); let custom_instructions = self.custom_instructions.clone(); let process_registry = self.process_registry.clone(); let cwd = self.cwd.clone(); @@ -11025,6 +11089,7 @@ impl App { tool_permissions, websearch_config, mcp_config, + compaction_config, cwd, None, messages, @@ -11873,6 +11938,22 @@ impl App { { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.ensure_session_view_state(&session_id); + let mut pending_message = crate::session::types::Message::user(&msg); + pending_message.local_image_paths = image_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + if self.compaction_receiver.is_none() + && !self.session_has_active_compaction(&session_id) + && self.should_auto_compact_current_session(Some(&pending_message)) + && self.queue_message_for_current_session(msg.clone(), image_paths.clone()) + { + self.start_compact_session_with_min( + &session_id, + crate::session::compaction::MIN_COMPACTABLE_TOKENS, + ); + return; + } } self.append_user_message_to_current_session(msg.clone(), image_paths); @@ -12735,6 +12816,7 @@ mod tests { editor: crate::config::EditorConfig::default(), pending_editor_suspend: None, websearch: crate::config::configuration::WebsearchConfig::default(), + compaction: crate::config::configuration::CompactionConfig::default(), mcp: crate::config::configuration::McpConfig::default(), mcp_manager: None, mcp_summary: crate::views::home::McpSummary::default(), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index eb820804..1822b0fc 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -501,6 +501,7 @@ pub enum CompactionConfig { Settings { auto: bool, prune: bool, + reserved: Option, }, } @@ -508,6 +509,28 @@ impl CompactionConfig { pub fn is_enabled(&self) -> bool { !matches!(self, Self::Disabled) } + + pub fn auto(&self) -> bool { + match self { + Self::Enabled => true, + Self::Disabled => false, + Self::Settings { auto, .. } => *auto, + } + } + + pub fn prune(&self) -> bool { + match self { + Self::Settings { prune, .. } => *prune, + Self::Enabled | Self::Disabled => false, + } + } + + pub fn reserved(&self) -> Option { + match self { + Self::Settings { reserved, .. } => *reserved, + Self::Enabled | Self::Disabled => None, + } + } } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -1494,6 +1517,10 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M .get("prune") .and_then(Value::as_bool) .unwrap_or(false), + reserved: settings + .get("reserved") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), }, _ => CompactionConfig::Enabled, }; @@ -2779,6 +2806,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "tui", "instructions", "tools", + "compaction", "watcher", "disabled_providers", "enabled_providers", @@ -2826,6 +2854,8 @@ mod tests { assert_eq!(config.instructions, vec!["AGENTS.md"]); assert_eq!(config.tools.get("bash"), Some(&false)); assert!(!config.compaction.is_enabled()); + assert!(!config.compaction.auto()); + assert!(!config.compaction.prune()); assert_eq!(config.watcher.ignored_paths(), ["generated", "tmp/cache"]); assert_eq!( config.formatter.get("rs"), @@ -2859,6 +2889,26 @@ mod tests { ); } + #[test] + fn parses_opencode_compaction_settings() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "compaction": { + "auto": true, + "prune": false, + "reserved": 10000 + } + }), + &mut diagnostics, + ); + + assert!(config.compaction.auto()); + assert!(!config.compaction.prune()); + assert_eq!(config.compaction.reserved(), Some(10_000)); + assert!(diagnostics.unimplemented_keys.is_empty()); + } + #[test] fn parses_tui_compact_mode_aliases() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/llm/client.rs b/src/llm/client.rs index 068e168d..38b5a0f1 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,7 +1,9 @@ use crate::agent::config::OpenAIRequestOptions; use crate::aisdk::core::{ chunk::{ChunkType, MessagePhase}, - response::{stream_with_tools, LanguageModelStream, StreamTextResponse}, + response::{ + stream_with_tools_options, LanguageModelStream, StreamTextResponse, StreamWithToolsOptions, + }, stop::StopReason, Message as AisdkMessage, Tool, }; @@ -48,6 +50,7 @@ struct ProviderRequestConfig { openai_options: OpenAIRequestOptions, /// Vercel AI Gateway: enable `providerOptions.gateway.caching = "auto"`. gateway_caching_auto: bool, + prune_tool_outputs: bool, } fn messages_have_user_audio(messages: &[crate::session::types::Message]) -> bool { @@ -119,6 +122,7 @@ impl ProviderRequestConfig { pricing: None, openai_options: OpenAIRequestOptions::default(), gateway_caching_auto: false, + prune_tool_outputs: false, } } } @@ -635,6 +639,7 @@ pub async fn stream_llm_with_cancellation( tool_permissions: crate::tools::ToolPermissions, websearch_config: crate::config::configuration::WebsearchConfig, mcp_config: crate::config::configuration::McpConfig, + compaction_config: crate::config::configuration::CompactionConfig, workspace: String, tool_registry: Option, messages: Vec, @@ -666,6 +671,7 @@ pub async fn stream_llm_with_cancellation( ) .await?; let mut request_config = request_config; + request_config.prune_tool_outputs = compaction_config.prune(); let model_mismatch_warning = ui_vs_request_model_mismatch_warning(&ui_model, &request_config.model_name); // Sticky prompt-cache routing: same key for every tool step in this session. @@ -710,6 +716,7 @@ pub async fn stream_llm_with_cancellation( openai_options: request_config.openai_options.clone(), prompt_cache_key: Some(session_id.clone()), gateway_caching_auto: request_config.gateway_caching_auto, + prune_tool_outputs: request_config.prune_tool_outputs, }; crate::agent::config::set_llm_session(llm_session.clone()); let session_registration = @@ -971,6 +978,7 @@ pub async fn build_subagent_llm_session( openai_options: request_config.openai_options, prompt_cache_key: None, gateway_caching_auto: request_config.gateway_caching_auto, + prune_tool_outputs: false, }) } @@ -1862,7 +1870,7 @@ async fn stream_provider_request( builder = builder.prompt_cache_key(cache_key); } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -1870,6 +1878,9 @@ async fn stream_provider_request( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: config.prune_tool_outputs, + }, ) .await .map_err(|e| Box::new(e) as DynError) @@ -1886,7 +1897,7 @@ async fn stream_provider_request( builder = builder.api_key(key); } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -1894,6 +1905,9 @@ async fn stream_provider_request( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: config.prune_tool_outputs, + }, ) .await .map_err(|e| Box::new(e) as DynError) @@ -1946,7 +1960,7 @@ async fn stream_provider_request( } let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; - stream_with_tools( + stream_with_tools_options( provider, messages, tools, @@ -1954,6 +1968,9 @@ async fn stream_provider_request( None, headers, cancel_token, + StreamWithToolsOptions { + prune_tool_outputs: config.prune_tool_outputs, + }, ) .await .map_err(|e| Box::new(e) as DynError) diff --git a/src/main.rs b/src/main.rs index 53c46f9b..dabfaf64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -471,6 +471,7 @@ async fn run_print_mode( let agent_registry = loaded_config.merged_config.agent_registry.clone(); let websearch_config = loaded_config.merged_config.websearch.clone(); let mcp_config = loaded_config.merged_config.mcp.clone(); + let compaction_config = loaded_config.merged_config.compaction.clone(); let agent_max_steps = agent_registry .get(&agent_mode) .and_then(|agent| agent.max_steps); @@ -529,6 +530,7 @@ async fn run_print_mode( tool_permissions, websearch_config, mcp_config, + compaction_config, cwd, Some(prompt_registry), messages, diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 2514db4d..68505862 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -817,12 +817,37 @@ impl Discovery { } pub fn get_model_limit(&self, provider_id: &str, model_id: &str) -> Option { + if let Some(limit) = self + .custom_providers + .as_ref() + .and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase())) + .and_then(|provider| provider.models.get(model_id)) + .and_then(|model| model.context_window) + { + return Some(limit); + } let entry = self.load_cache_entry().ok()??; let provider = entry.data.get(provider_id)?; let model = provider.models.get(model_id)?; model.limit.as_ref().map(|l| l.context) } + pub fn get_model_output_limit(&self, provider_id: &str, model_id: &str) -> Option { + if let Some(limit) = self + .custom_providers + .as_ref() + .and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase())) + .and_then(|provider| provider.models.get(model_id)) + .and_then(|model| model.max_tokens) + { + return Some(limit); + } + let entry = self.load_cache_entry().ok()??; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + model.limit.as_ref().map(|limit| limit.output) + } + pub fn model_supports_input_modality( &self, provider_id: &str, diff --git a/src/session/compaction.rs b/src/session/compaction.rs index 2ccff834..ea741e1e 100644 --- a/src/session/compaction.rs +++ b/src/session/compaction.rs @@ -12,6 +12,7 @@ pub const DEFAULT_PRESERVE_RECENT_TOKENS: usize = 12_000; /// Minimum tokens in the head (to summarize) before compaction is worth running. /// Grok default is 5k; 2k still skips tiny heads that a fat summary would inflate. pub const MIN_COMPACTABLE_TOKENS: usize = 2_000; +pub const DEFAULT_RESERVED_TOKENS: u32 = 20_000; pub const SUMMARY_PREFIX: &str = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"; pub const COMPACTION_MARKER_CONTENT: &str = "[crabcode:context-compacted]"; @@ -55,6 +56,33 @@ Rules: const TOOL_OUTPUT_MAX_CHARS: usize = 2_000; +pub fn auto_compaction_threshold( + config: &crate::config::configuration::CompactionConfig, + context_window: Option, + max_output_tokens: Option, +) -> Option { + if !config.auto() { + return None; + } + let context_window = context_window.filter(|limit| *limit > 0)?; + let reserved = config.reserved().unwrap_or_else(|| { + max_output_tokens + .unwrap_or(DEFAULT_RESERVED_TOKENS) + .min(DEFAULT_RESERVED_TOKENS) + }); + Some(context_window.saturating_sub(reserved) as usize) +} + +pub fn should_auto_compact( + config: &crate::config::configuration::CompactionConfig, + used_tokens: usize, + context_window: Option, + max_output_tokens: Option, +) -> bool { + auto_compaction_threshold(config, context_window, max_output_tokens) + .is_some_and(|threshold| used_tokens >= threshold) +} + #[derive(Debug, Clone, PartialEq)] pub struct CompactionSelection { /// Absolute insert index in the full transcript: keep `messages[..summarize_end]`, @@ -711,6 +739,61 @@ fn estimate_tokens(content: &str) -> usize { mod tests { use super::*; + #[test] + fn auto_compaction_uses_explicit_reserved_tokens() { + let config = crate::config::configuration::CompactionConfig::Settings { + auto: true, + prune: false, + reserved: Some(10_000), + }; + assert_eq!( + auto_compaction_threshold(&config, Some(128_000), Some(32_000)), + Some(118_000) + ); + assert!(!should_auto_compact( + &config, + 117_999, + Some(128_000), + Some(32_000) + )); + assert!(should_auto_compact( + &config, + 118_000, + Some(128_000), + Some(32_000) + )); + } + + #[test] + fn auto_compaction_falls_back_to_output_or_twenty_thousand_tokens() { + let config = crate::config::configuration::CompactionConfig::Enabled; + assert_eq!( + auto_compaction_threshold(&config, Some(128_000), Some(8_192)), + Some(119_808) + ); + assert_eq!( + auto_compaction_threshold(&config, Some(128_000), Some(64_000)), + Some(108_000) + ); + } + + #[test] + fn auto_compaction_requires_auto_and_known_context_window() { + let disabled = crate::config::configuration::CompactionConfig::Disabled; + assert_eq!( + auto_compaction_threshold(&disabled, Some(128_000), Some(8_192)), + None + ); + assert_eq!( + auto_compaction_threshold( + &crate::config::configuration::CompactionConfig::Enabled, + None, + Some(8_192) + ), + None + ); + } + #[test] fn select_messages_drops_heavy_tail_turn_over_preserve_budget() { // Last turn alone exceeds preserve budget → empty tail, whole session summarized. diff --git a/src/tools/task.rs b/src/tools/task.rs index 11ba23b5..c5ba59f4 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -132,6 +132,7 @@ mod tests { openai_options: crate::agent::config::OpenAIRequestOptions::default(), prompt_cache_key: None, gateway_caching_auto: false, + prune_tool_outputs: false, }, ); @@ -211,6 +212,7 @@ mod tests { openai_options: crate::agent::config::OpenAIRequestOptions::default(), prompt_cache_key: None, gateway_caching_auto: false, + prune_tool_outputs: false, }; let mut warnings = Vec::new(); @@ -250,6 +252,7 @@ mod tests { openai_options: crate::agent::config::OpenAIRequestOptions::default(), prompt_cache_key: None, gateway_caching_auto: false, + prune_tool_outputs: false, }; let mut warnings = Vec::new(); let defs = crate::agent::definition::parse_agent_definitions_from_config( From 6edd9c95b8671472f77eee22efe30851e70f7d3e Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 10 Sep 2026 17:18:21 +0700 Subject: [PATCH 22/26] fix(acp): stream live context usage --- _docs/acp.mdx | 2 +- src/acp/service.rs | 298 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 292 insertions(+), 8 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 544cd029..3c392288 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -55,7 +55,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | MCP | Project MCP and client-supplied stdio, HTTP, and SSE servers merge into the session. Static headers, structured results, live status, resources, annotations, images, audio, and metadata are preserved. Project-configured remote MCP continues to use Crabcode's OAuth credential flow. | Full | ACP currently advertises only the HTTP/SSE transport flags; its client-server schema has no stdio flag or remote OAuth fields. Client-supplied remote auth can still be provided through headers. | | Terminals | `terminal_session` and terminal-mode `bash` use the editor terminal host through create, embed, wait, output, kill, and release. Output is bounded for the model, and cancellation also covers terminal creation. | Full | The editor must advertise terminal hosting. User input and resize happen directly in the embedded editor terminal because ACP has no agent-issued stdin/resize requests. | | Questions | Agent questions use capability-gated ACP form elicitation with validated non-empty prompts/options, unique labels, ordered single/multi-select answers, custom text, cardinality checks, deduplication, length bounds, cancellation, and safe skip behavior. | Full | Form elicitation is an unstable ACP capability and is only sent to editors that advertise it. | -| Usage | Provider input/output/cache-read/cache-write usage is aggregated across multi-step turns and persisted. ACP always receives context occupancy and cumulative USD cost updates; detailed token/cache values and whether the context size is known are included in `crabcode` `_meta`. Context-window size resolves from the effective selectable model catalog first, then discovery or custom-provider metadata. Catalog pricing is cache-aware. | Full | Providers that omit usage or models without pricing cannot supply authoritative token or cost data; Crabcode still emits estimated context occupancy and marks unknown context size in metadata. | +| Usage | Provider input/output/cache-read/cache-write usage is aggregated across multi-step turns and persisted. ACP receives live context occupancy updates while text, reasoning, tool calls, and tool results stream, plus provider-reported prompt/cache floors after each model step and cumulative USD cost. Detailed token/cache values and whether the context size is known are included in `crabcode` `_meta`. Context-window size resolves from the effective selectable model catalog first, then discovery or custom-provider metadata. Catalog pricing is cache-aware. | Full | Providers that omit usage or models without pricing cannot supply authoritative token or cost data; Crabcode still emits live estimated context growth and marks unknown context size in metadata. | ## Runtime requirements diff --git a/src/acp/service.rs b/src/acp/service.rs index eee280b5..a06bd7e9 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -1406,29 +1406,65 @@ impl AcpService { let mut failed = None; let mut cancelled = false; let mut turn_stop_reason = None; + let mut live_usage = AcpLiveUsage::default(); while let Some(chunk) = receiver.recv().await { match chunk { crate::llm::ChunkMessage::Text(text) => { assistant.append(&text); send_text(&connection, &session_id, &message_id, text, false)?; + send_live_usage( + &connection, + &session_id, + &session, + base_context_tokens, + base_cost, + &assistant, + &live_usage, + )?; } crate::llm::ChunkMessage::Reasoning(text) => { assistant.append_reasoning(&text); send_text(&connection, &session_id, &message_id, text, true)?; + send_live_usage( + &connection, + &session_id, + &session, + base_context_tokens, + base_cost, + &assistant, + &live_usage, + )?; } crate::llm::ChunkMessage::ToolCalls(tool_calls) => { + for tool_call in &tool_calls { + record_tool_call(&mut assistant, tool_call); + } for tool_call in tool_calls { send_tool_call(&connection, &session_id, tool_call, &session.cwd)?; } + send_live_usage( + &connection, + &session_id, + &session, + base_context_tokens, + base_cost, + &assistant, + &live_usage, + )?; } crate::llm::ChunkMessage::ToolResult(result) => { - assistant.add_or_update_tool_result_part(serde_json::json!({ - "id": result.tool_call_id, - "name": result.name, - "content": result.content, - })); + record_tool_result(&mut assistant, &result); send_tool_result(&connection, &session_id, result, &session.cwd)?; + send_live_usage( + &connection, + &session_id, + &session, + base_context_tokens, + base_cost, + &assistant, + &live_usage, + )?; } crate::llm::ChunkMessage::Metrics { token_count, @@ -1440,17 +1476,23 @@ impl AcpService { assistant.duration_ms = Some(duration_ms); if let Some(usage) = usage { assistant.apply_usage(usage, cost); + live_usage.cumulative = usage; } + live_usage.cost = cost.unwrap_or(live_usage.cost); send_usage( &connection, &session_id, &session, - base_context_tokens.saturating_add(token_count), - cost.map(|turn_cost| base_cost + turn_cost), + live_usage + .context_tokens(base_context_tokens, &assistant) + .max(base_context_tokens.saturating_add(token_count)), + cumulative_cost(base_cost, live_usage.cost), usage, )?; } crate::llm::ChunkMessage::Usage(usage) => { + live_usage.observe_provider_usage(&assistant, usage); + live_usage.cost += estimate_session_usage_cost(&session, &usage); assistant .parts .push(crate::session::types::MessagePart::usage( @@ -1468,6 +1510,15 @@ impl AcpService { .saturating_add(usage.output as usize), ); } + send_live_usage( + &connection, + &session_id, + &session, + base_context_tokens, + base_cost, + &assistant, + &live_usage, + )?; } crate::llm::ChunkMessage::Cancelled => cancelled = true, crate::llm::ChunkMessage::Failed(error) => failed = Some(error), @@ -2689,6 +2740,160 @@ fn send_usage( .map_err(|_| internal_error()) } +fn send_live_usage( + connection: &ConnectionTo, + session_id: &str, + session: &AcpSession, + base_context_tokens: usize, + base_cost: f64, + assistant: &crate::session::types::Message, + live_usage: &AcpLiveUsage, +) -> Result<(), Error> { + send_usage( + connection, + session_id, + session, + live_usage.context_tokens(base_context_tokens, assistant), + cumulative_cost(base_cost, live_usage.cost), + (!live_usage.cumulative.is_empty()).then_some(live_usage.cumulative), + ) +} + +#[derive(Debug, Default)] +struct AcpLiveUsage { + cumulative: crate::aisdk::chunk::TokenUsage, + provider_context_floor: usize, + cost: f64, +} + +impl AcpLiveUsage { + fn observe_provider_usage( + &mut self, + assistant: &crate::session::types::Message, + usage: crate::aisdk::chunk::TokenUsage, + ) { + self.cumulative = self.cumulative.saturating_add(usage); + let provider_input = usage + .input + .saturating_add(usage.cache_read) + .saturating_add(usage.cache_write); + let observed_assistant = live_assistant_context_tokens(assistant); + self.provider_context_floor = self + .provider_context_floor + .max(usize::try_from(provider_input).unwrap_or(usize::MAX)) + .max(observed_assistant); + } + + fn context_tokens( + &self, + base_context_tokens: usize, + assistant: &crate::session::types::Message, + ) -> usize { + base_context_tokens + .saturating_add(live_assistant_context_tokens(assistant)) + .max(self.provider_context_floor) + } +} + +fn live_assistant_context_tokens(assistant: &crate::session::types::Message) -> usize { + let persisted = crate::session::compaction::message_context_tokens(assistant); + let reasoning = assistant + .reasoning + .as_deref() + .map(estimate_acp_tokens) + .unwrap_or(0); + persisted.saturating_add(reasoning) +} + +fn estimate_acp_tokens(content: &str) -> usize { + content.chars().count().saturating_add(3) / 4 +} + +fn cumulative_cost(base_cost: f64, turn_cost: f64) -> Option { + let cost = base_cost + turn_cost; + (cost > 0.0).then_some(cost) +} + +fn record_tool_call( + assistant: &mut crate::session::types::Message, + tool_call: &crate::llm::ToolCall, +) { + let args = serde_json::from_str(&tool_call.function.arguments) + .unwrap_or_else(|_| serde_json::Value::String(tool_call.function.arguments.clone())); + let provider_executed = matches!( + tool_call.function.name.as_str(), + "x_search" | "web_search" | "file_search" + ); + + if let Some(part) = assistant + .parts + .iter_mut() + .find(|part| part.part_type == "tool_call" && part.tool_id() == Some(tool_call.id.as_str())) + { + if let Some(obj) = part.data.as_object_mut() { + obj.insert( + "name".to_string(), + serde_json::Value::String(tool_call.function.name.clone()), + ); + obj.insert("args".to_string(), args); + if provider_executed { + obj.insert( + "provider_executed".to_string(), + serde_json::Value::Bool(true), + ); + } + } + return; + } + + assistant.add_tool_call_part(tool_call.id.clone(), tool_call.function.name.clone(), args); + if provider_executed { + if let Some(obj) = assistant + .parts + .last_mut() + .and_then(|part| part.data.as_object_mut()) + { + obj.insert( + "provider_executed".to_string(), + serde_json::Value::Bool(true), + ); + } + } +} + +fn record_tool_result( + assistant: &mut crate::session::types::Message, + result: &crate::llm::ToolCallResult, +) { + let mut data = assistant + .tool_call_part_data(&result.tool_call_id) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + data["id"] = serde_json::Value::String(result.tool_call_id.clone()); + data["name"] = serde_json::Value::String(result.name.clone()); + if matches!( + result.name.as_str(), + "x_search" | "web_search" | "file_search" + ) { + data["provider_executed"] = serde_json::Value::Bool(true); + } + + match serde_json::from_str::(&result.content) { + Ok(serde_json::Value::Object(payload)) => { + if let Some(data) = data.as_object_mut() { + for (key, value) in payload { + data.insert(key, value); + } + } + } + _ => { + data["status"] = serde_json::Value::String("ok".to_string()); + data["output_preview"] = serde_json::Value::String(result.content.clone()); + } + } + assistant.add_or_update_tool_result_part(data); +} + fn usage_update( session: &AcpSession, used: usize, @@ -2995,6 +3200,85 @@ mod tests { assert_eq!(meta["crabcode"]["usage"]["cacheWriteTokens"], 100); } + #[test] + fn acp_live_usage_advances_with_streamed_tools_and_provider_context() { + let mut assistant = crate::session::types::Message::incomplete("hello"); + record_tool_call( + &mut assistant, + &crate::llm::ToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: "read".to_string(), + arguments: serde_json::json!({ "filePath": "src/main.rs" }).to_string(), + }, + }, + ); + record_tool_result( + &mut assistant, + &crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "read".to_string(), + content: serde_json::json!({ + "status": "ok", + "output_preview": "x".repeat(4_000), + }) + .to_string(), + }, + ); + + let mut live = AcpLiveUsage::default(); + let before_provider_usage = live.context_tokens(18_000, &assistant); + assert!(before_provider_usage > 18_000); + assert!(assistant + .tool_call_part_data("call_1") + .and_then(|part| part.get("args")) + .is_some()); + + live.observe_provider_usage( + &assistant, + crate::aisdk::chunk::TokenUsage { + input: 70_000, + output: 500, + cache_read: 20_000, + cache_write: 0, + }, + ); + assert_eq!(live.context_tokens(18_000, &assistant), 90_000); + assert_eq!(live.cumulative.input, 70_000); + assert_eq!(live.cumulative.cache_read, 20_000); + } + + #[test] + fn acp_live_usage_keeps_largest_provider_context_across_tool_steps() { + let assistant = crate::session::types::Message::incomplete(""); + let mut live = AcpLiveUsage::default(); + live.observe_provider_usage( + &assistant, + crate::aisdk::chunk::TokenUsage { + input: 60_000, + output: 200, + cache_read: 20_000, + cache_write: 0, + }, + ); + live.observe_provider_usage( + &assistant, + crate::aisdk::chunk::TokenUsage { + input: 10_000, + output: 100, + cache_read: 40_000, + cache_write: 0, + }, + ); + + assert_eq!(live.context_tokens(18_000, &assistant), 80_000); + assert_eq!(live.cumulative.input, 70_000); + assert_eq!(live.cumulative.output, 300); + assert_eq!(live.cumulative.cache_read, 60_000); + } + #[test] fn session_info_includes_hierarchy_metadata() { let now = std::time::SystemTime::now(); From 6f4e8df322d001962f27be5ab056e5046625ecd0 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 10 Sep 2026 17:22:07 +0700 Subject: [PATCH 23/26] style(llm): format merged imports --- src/llm/client.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/llm/client.rs b/src/llm/client.rs index 3d6efaf0..1f7a8614 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -2932,8 +2932,7 @@ mod tests { convert_messages, convert_messages_for_model, convert_messages_for_model_with_audio, maybe_apply_unauthenticated_free_provider_key, model_supports_image_input, openai_oauth_default_originator, openai_oauth_model_uses_responses_lite, - openai_request_instructions, provider_kind_for_model, resolve_api_key, - resolve_model_route, + openai_request_instructions, provider_kind_for_model, resolve_api_key, resolve_model_route, ui_vs_request_model_mismatch_warning, vlm_agent_has_model, AisdkMessage, OpenAIRequestOptions, ProviderKind, ProviderRequestConfig, }; From 6b4f1f08926c1feaf578b50b26ebf71d77a1a772 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Tue, 15 Sep 2026 08:56:17 +0700 Subject: [PATCH 24/26] fix(acp): keep transport open on session errors --- _docs/acp.mdx | 4 +- src/acp/server.rs | 132 +++++++++++++++++++++++++++++---------------- tests/acp_stdio.rs | 32 +++++++++++ 3 files changed, 120 insertions(+), 48 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index 3c392288..e1b2de5d 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -44,7 +44,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | Area | Full behavior | Status | Runtime or protocol requirements | | --- | --- | --- | --- | -| Transport | JSON-RPC over stdio through `crabcode acp`, protocol-only stdout, clean stdin EOF shutdown, and subprocess coverage for initialize, session creation, command advertisement, and command dispatch. | Full | The subprocess wrapper must not write banners or logs to stdout. | +| Transport | JSON-RPC over stdio through `crabcode acp`, protocol-only stdout, clean stdin EOF shutdown, request failures returned as JSON-RPC errors without closing the connection, and subprocess coverage for initialize, session loading failures, session creation, command advertisement, and command dispatch. | Full | The subprocess wrapper must not write banners or logs to stdout. | | Sessions | Create, cursor-list, load, resume, close, delete, and fork all persisted sessions, including child sessions. Lists include Crabcode parent/root IDs in ACP `_meta`; forks preserve the source title, regenerate message IDs, copy attachments independently, and publish commands for the new session. Delete removes persisted history and managed attachments. | Full | ACP has no standard nested-session tree field, so hierarchy is exposed through the `crabcode` metadata extension while the standard list remains flat. | | Prompts | Text, embedded resources, PNG/JPEG/GIF/WebP images, and WAV/MP3 audio; assistant text and reasoning stream back to the editor. Attachments use private per-session storage, survive load/resume, copy independently on fork, delete with persisted sessions, and readable legacy paths migrate automatically on load. | Full | The selected model route must advertise the matching input modality. Audio uses verified OpenAI-compatible Chat Completions `input_audio`; unsupported provider transports return a clear error instead of dropping media. | | Modes and models | Visible primary agents, selectable model catalog entries, and supported reasoning-effort values are session-local ACP configuration options. | Full | Available reasoning values follow the selected model's catalog capability. | @@ -66,7 +66,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the ## Session behavior -`session/close` detaches the editor and cancels any active turn. It does not delete Crabcode session history. `session/delete` removes persisted history and managed attachments. List cursors page through the complete non-archived result set. Load replays the stored transcript; resume restores the session configuration without replaying prior content. Fork creates a new persisted session with a copied transcript and independently managed attachments. Child sessions are listed as normal entries with hierarchy metadata under `_meta.crabcode`. +`session/close` detaches the editor and cancels any active turn. It does not delete Crabcode session history. `session/delete` removes persisted history and managed attachments. List cursors page through the complete non-archived result set. Load replays the stored transcript; resume restores the session configuration without replaying prior content. Fork creates a new persisted session with a copied transcript and independently managed attachments. Child sessions are listed as normal entries with hierarchy metadata under `_meta.crabcode`. Invalid or failed session operations return a normal JSON-RPC error response and leave the ACP stdio connection available for later requests. ## Safety notes diff --git a/src/acp/server.rs b/src/acp/server.rs index 49c2cfc0..16a8a213 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -67,20 +67,30 @@ pub async fn run(cwd: Option) -> Result<()> { let service = service.clone(); let task_connection = connection.clone(); connection.spawn(async move { - let response = service - .fork_session(request.session_id.to_string(), request.cwd) - .await?; - let session_id = response.session_id.clone(); - let commands = service.available_commands(&session_id.to_string()).await?; - responder.respond( - ForkSessionResponse::new(session_id.clone()) - .modes(response.modes) - .config_options(response.config_options), - )?; - task_connection.send_notification(SessionNotification::new( - session_id, - SessionUpdate::AvailableCommandsUpdate(commands), - )) + let result: Result<_, agent_client_protocol::Error> = async { + let response = service + .fork_session(request.session_id.to_string(), request.cwd) + .await?; + let session_id = response.session_id.clone(); + let commands = + service.available_commands(&session_id.to_string()).await?; + Ok((response, session_id, commands)) + } + .await; + match result { + Ok((response, session_id, commands)) => { + responder.respond( + ForkSessionResponse::new(session_id.clone()) + .modes(response.modes) + .config_options(response.config_options), + )?; + task_connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(commands), + )) + } + Err(error) => responder.respond_with_result(Err(error)), + } }) } }, @@ -148,19 +158,29 @@ pub async fn run(cwd: Option) -> Result<()> { let service = service.clone(); let task_connection = connection.clone(); connection.spawn(async move { - let response = service - .load_session( - session_id.to_string(), - request.cwd, - task_connection.clone(), - ) - .await?; - let commands = service.available_commands(&session_id.to_string()).await?; - responder.respond(response)?; - task_connection.send_notification(SessionNotification::new( - session_id, - SessionUpdate::AvailableCommandsUpdate(commands), - )) + let result: Result<_, agent_client_protocol::Error> = async { + let response = service + .load_session( + session_id.to_string(), + request.cwd, + task_connection.clone(), + ) + .await?; + let commands = + service.available_commands(&session_id.to_string()).await?; + Ok((response, commands)) + } + .await; + match result { + Ok((response, commands)) => { + responder.respond(response)?; + task_connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(commands), + )) + } + Err(error) => responder.respond_with_result(Err(error)), + } }) } }, @@ -174,15 +194,25 @@ pub async fn run(cwd: Option) -> Result<()> { let service = service.clone(); let task_connection = connection.clone(); connection.spawn(async move { - let response = service - .resume_session(session_id.to_string(), request.cwd) - .await?; - let commands = service.available_commands(&session_id.to_string()).await?; - responder.respond(response)?; - task_connection.send_notification(SessionNotification::new( - session_id, - SessionUpdate::AvailableCommandsUpdate(commands), - )) + let result: Result<_, agent_client_protocol::Error> = async { + let response = service + .resume_session(session_id.to_string(), request.cwd) + .await?; + let commands = + service.available_commands(&session_id.to_string()).await?; + Ok((response, commands)) + } + .await; + match result { + Ok((response, commands)) => { + responder.respond(response)?; + task_connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(commands), + )) + } + Err(error) => responder.respond_with_result(Err(error)), + } }) } }, @@ -195,16 +225,26 @@ pub async fn run(cwd: Option) -> Result<()> { let service = service.clone(); let task_connection = connection.clone(); connection.spawn(async move { - let response = service - .new_session(request.cwd, request.mcp_servers) - .await?; - let session_id = response.session_id.clone(); - let commands = service.available_commands(&session_id.to_string()).await?; - responder.respond(response)?; - task_connection.send_notification(SessionNotification::new( - session_id, - SessionUpdate::AvailableCommandsUpdate(commands), - )) + let result: Result<_, agent_client_protocol::Error> = async { + let response = service + .new_session(request.cwd, request.mcp_servers) + .await?; + let session_id = response.session_id.clone(); + let commands = + service.available_commands(&session_id.to_string()).await?; + Ok((response, session_id, commands)) + } + .await; + match result { + Ok((response, session_id, commands)) => { + responder.respond(response)?; + task_connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AvailableCommandsUpdate(commands), + )) + } + Err(error) => responder.respond_with_result(Err(error)), + } }) } }, diff --git a/tests/acp_stdio.rs b/tests/acp_stdio.rs index b0c49e6b..d030e2c2 100644 --- a/tests/acp_stdio.rs +++ b/tests/acp_stdio.rs @@ -136,6 +136,38 @@ fn initialize_over_stdio_and_shutdown_on_eof() { process.close_and_wait(); } +#[test] +fn session_load_error_keeps_stdio_transport_open() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut process = AcpProcess::spawn(workspace.path()); + initialize(&mut process); + + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/load", + "params": { + "sessionId": "missing-session", + "cwd": workspace.path(), + "mcpServers": [] + } + })); + let (load_response, _) = process.recv_response(2); + assert_eq!(load_response["error"]["code"], -32602); + assert_eq!(load_response["error"]["data"], "unknown session"); + + process.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/list", + "params": {} + })); + let (list_response, _) = process.recv_response(3); + assert_eq!(list_response["result"]["sessions"], serde_json::json!([])); + + process.close_and_wait(); +} + #[test] fn advertises_and_dispatches_commands_over_stdio() { let workspace = tempfile::tempdir().expect("workspace"); From 3c27e4be55f75f8bac34eb0dbe427093c8a861b2 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Tue, 15 Sep 2026 13:59:50 +0700 Subject: [PATCH 25/26] fix(acp): remember permission grants per session --- _docs/acp.mdx | 4 +- src/acp/service.rs | 120 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index e1b2de5d..b2a92e87 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -49,7 +49,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | Prompts | Text, embedded resources, PNG/JPEG/GIF/WebP images, and WAV/MP3 audio; assistant text and reasoning stream back to the editor. Attachments use private per-session storage, survive load/resume, copy independently on fork, delete with persisted sessions, and readable legacy paths migrate automatically on load. | Full | The selected model route must advertise the matching input modality. Audio uses verified OpenAI-compatible Chat Completions `input_audio`; unsupported provider transports return a clear error instead of dropping media. | | Modes and models | Visible primary agents, selectable model catalog entries, and supported reasoning-effort values are session-local ACP configuration options. | Full | Available reasoning values follow the selected model's catalog capability. | | Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | -| Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow, and reject choices. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | +| Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow for this session, and reject choices. Session grants are shared across turns and cover later accesses within the approved folder scope. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | | Cancellation | `session/cancel` interrupts model turns, questions, compaction, and terminal creation/execution while keeping the session reusable. Crabcode maps completion, output limit, configured turn limit, refusal/content filtering, and cancellation to ACP `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. | Full | Provider failures that are not normal stop conditions remain JSON-RPC/tool errors, as required by ACP's stop-reason model. | | Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, `/compact`, and `/btw`. Commands are dispatched from regular ACP text prompts as required by the protocol. Custom command agent/model overrides apply to that turn while embedded resources and media remain attached. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context, while OpenCode-compatible `compaction.auto`, `compaction.prune`, and `compaction.reserved` also apply to normal ACP turns. `/btw` runs a no-tools side question without changing the main transcript. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | | MCP | Project MCP and client-supplied stdio, HTTP, and SSE servers merge into the session. Static headers, structured results, live status, resources, annotations, images, audio, and metadata are preserved. Project-configured remote MCP continues to use Crabcode's OAuth credential flow. | Full | ACP currently advertises only the HTTP/SSE transport flags; its client-server schema has no stdio flag or remote OAuth fields. Client-supplied remote auth can still be provided through headers. | @@ -70,7 +70,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the ## Safety notes -Crabcode applies the same configured permission rules in ACP as it does in the TUI. When a tool needs approval, the editor receives an ACP permission request. If the editor cannot respond or disconnects, Crabcode denies the request rather than continuing unattended. +Crabcode applies the same configured permission rules in ACP as it does in the TUI. When a tool needs approval, the editor receives an ACP permission request with **Allow once**, **Always allow for this session**, and **Reject** choices. Choosing the session-wide option remembers the normalized permission scope for that attached ACP session, including later tool calls and later prompt turns. For external-folder access, approving a folder also covers files and nested directories beneath it. The grant is intentionally not written to project configuration or global preferences: closing/detaching the ACP session or restarting the server clears it, and loading/resuming the persisted transcript starts with a fresh permission state. Forked sessions also start with fresh grants. If the editor cannot respond or disconnects, Crabcode denies the request rather than continuing unattended. Question forms are only sent to editors that advertise ACP form elicitation support. Declining, cancelling, disconnecting, or using an editor without that capability returns empty answers to the agent so the session can continue without waiting indefinitely. diff --git a/src/acp/service.rs b/src/acp/service.rs index a06bd7e9..98ef3bc0 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -30,6 +30,18 @@ pub struct AcpService { client_capabilities: Arc>, } +fn permission_options() -> Vec { + vec![ + PermissionOption::new("once", "Allow once", PermissionOptionKind::AllowOnce), + PermissionOption::new( + "always", + "Always allow for this session", + PermissionOptionKind::AllowAlways, + ), + PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce), + ] +} + fn model_output_limit(config: &LoadedConfig, provider_id: &str, model_id: &str) -> Option { if let Some(limit) = config .merged_config @@ -753,6 +765,7 @@ fn merge_acp_mcp_servers(config: &mut LoadedConfig, servers: Vec) { struct AcpSession { cwd: PathBuf, config: LoadedConfig, + tool_permissions: crate::tools::ToolPermissions, skills: crate::skill::SkillStore, models: Vec, provider: String, @@ -851,11 +864,13 @@ impl AcpService { }; let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); + let tool_permissions = configured_tool_permissions(&cwd, &config); self.sessions.lock().await.insert( session_id.clone(), AcpSession { cwd, config, + tool_permissions, skills, models, provider, @@ -970,10 +985,13 @@ impl AcpService { } fork_id }; - self.sessions - .lock() - .await - .insert(fork_id.clone(), source.clone()); + self.sessions.lock().await.insert( + fork_id.clone(), + AcpSession { + tool_permissions: configured_tool_permissions(&source.cwd, &source.config), + ..source.clone() + }, + ); Ok(NewSessionResponse::new(fork_id) .modes(session_modes(&source)) .config_options(session_config_options(&source))) @@ -1125,9 +1143,11 @@ impl AcpService { reasoning.unwrap_or(crate::model::reasoning::ReasoningEffort::None); let context_window = model_context_window(&config, &models, &provider, &model); let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); + let tool_permissions = configured_tool_permissions(&cwd, &config); let session = AcpSession { cwd, config, + tool_permissions, skills, models, provider, @@ -1918,26 +1938,19 @@ fn resolve_model(config: &LoadedConfig) -> (String, String) { .unwrap_or_else(|| ("opencode".to_string(), "big-pickle".to_string())) } -fn tool_permissions(session: &AcpSession) -> crate::tools::ToolPermissions { +fn configured_tool_permissions(cwd: &Path, config: &LoadedConfig) -> crate::tools::ToolPermissions { let mut policies = crate::tools::AgentToolPolicies::default(); - for (mode, tools) in session - .config - .merged_config - .agent_registry - .tool_policy_map() - { + for (mode, tools) in config.merged_config.agent_registry.tool_policy_map() { policies = policies.with_custom_tools(mode, tools); } - crate::tools::ToolPermissions::new(&session.cwd) + crate::tools::ToolPermissions::new(cwd) .with_agent_policies(policies) - .with_permission_rules(session.config.merged_config.permission_rules.clone()) - .with_agent_permission_rules( - session - .config - .merged_config - .agent_registry - .permission_rules_map(), - ) + .with_permission_rules(config.merged_config.permission_rules.clone()) + .with_agent_permission_rules(config.merged_config.agent_registry.permission_rules_map()) +} + +fn tool_permissions(session: &AcpSession) -> crate::tools::ToolPermissions { + session.tool_permissions.clone() } fn session_modes(session: &AcpSession) -> SessionModeState { @@ -2508,15 +2521,8 @@ async fn request_permission( } fields }); - let request = RequestPermissionRequest::new( - session_id.to_string(), - tool_call, - vec![ - PermissionOption::new("once", "Allow once", PermissionOptionKind::AllowOnce), - PermissionOption::new("always", "Always allow", PermissionOptionKind::AllowAlways), - PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce), - ], - ); + let request = + RequestPermissionRequest::new(session_id.to_string(), tool_call, permission_options()); let Ok(response) = connection.send_request(request).block_task().await else { return crate::tools::PermissionResponse::Deny; }; @@ -3159,6 +3165,7 @@ mod tests { cwd: PathBuf::from("/tmp"), xdg_config_home: PathBuf::from("/tmp"), }, + tool_permissions: crate::tools::ToolPermissions::new("/tmp"), skills: crate::skill::SkillStore::load(Path::new("/tmp"), Path::new("/tmp")), models: vec![model("example", "Example", "chat", "Chat")], provider: "example".to_string(), @@ -3442,6 +3449,60 @@ mod tests { assert!(permission_tool_call_id(None).starts_with("permission:")); } + #[test] + fn acp_permission_offers_explicit_session_wide_allow() { + let options = permission_options(); + let always = options + .iter() + .find(|option| option.option_id.to_string() == "always") + .expect("always option"); + assert_eq!(always.name, "Always allow for this session"); + assert_eq!(always.kind, PermissionOptionKind::AllowAlways); + } + + #[tokio::test] + async fn acp_session_reuses_permission_grants_across_turns() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + let external = temp.path().join("external"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&external).expect("external"); + + let mut session = test_session(); + session.cwd = workspace.clone(); + session.tool_permissions = crate::tools::ToolPermissions::new(&workspace); + let first_turn = tool_permissions(&session); + let second_turn = tool_permissions(&session); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": external.join("one.txt") }); + + let pending = tokio::spawn({ + let permissions = first_turn.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { + permissions + .preflight("build", "read", ¶ms, Some(&tx)) + .await + } + }); + let prompt = match rx.recv().await { + Some(crate::llm::ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("expected permission request"), + }; + let _ = prompt + .response_tx + .send(crate::tools::PermissionResponse::AllowAlways); + assert!(pending.await.expect("permission task").is_ok()); + + let next_params = serde_json::json!({ "file_path": external.join("nested/two.txt") }); + assert!(second_turn + .preflight("build", "read", &next_params, Some(&tx)) + .await + .is_ok()); + assert!(rx.try_recv().is_err()); + } + #[test] fn acp_permission_edit_includes_preflight_diff() { let dir = tempfile::tempdir().unwrap(); @@ -3906,6 +3967,7 @@ mod tests { let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root); AcpSession { cwd: config.cwd.clone(), + tool_permissions: configured_tool_permissions(&config.cwd, &config), config, skills, models: Vec::new(), From c6f9fb0c4de8a4b468aca659b016383fb3d2ffb7 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Tue, 15 Sep 2026 14:33:15 +0700 Subject: [PATCH 26/26] fix(tools): sanitize unsupported schema regex --- _docs/acp.mdx | 2 +- src/aisdk/providers/compatible.rs | 2 +- src/aisdk/providers/openai.rs | 43 ++++++- src/aisdk/tool.rs | 187 ++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 3 deletions(-) diff --git a/_docs/acp.mdx b/_docs/acp.mdx index b2a92e87..e8c6b84d 100644 --- a/_docs/acp.mdx +++ b/_docs/acp.mdx @@ -48,7 +48,7 @@ All Crabcode-side capabilities in this matrix are implemented. Conditions in the | Sessions | Create, cursor-list, load, resume, close, delete, and fork all persisted sessions, including child sessions. Lists include Crabcode parent/root IDs in ACP `_meta`; forks preserve the source title, regenerate message IDs, copy attachments independently, and publish commands for the new session. Delete removes persisted history and managed attachments. | Full | ACP has no standard nested-session tree field, so hierarchy is exposed through the `crabcode` metadata extension while the standard list remains flat. | | Prompts | Text, embedded resources, PNG/JPEG/GIF/WebP images, and WAV/MP3 audio; assistant text and reasoning stream back to the editor. Attachments use private per-session storage, survive load/resume, copy independently on fork, delete with persisted sessions, and readable legacy paths migrate automatically on load. | Full | The selected model route must advertise the matching input modality. Audio uses verified OpenAI-compatible Chat Completions `input_audio`; unsupported provider transports return a clear error instead of dropping media. | | Modes and models | Visible primary agents, selectable model catalog entries, and supported reasoning-effort values are session-local ACP configuration options. | Full | Available reasoning values follow the selected model's catalog capability. | -| Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | +| Tools | Pending and completed/failed tool calls include ACP kinds, titles, raw input/output, full text plus bounded previews, normalized locations, native editor images/audio/resources, annotations, metadata, and full-file diffs. The model receives the structured textual/raw representation and supported image results. Unknown future MCP blocks are preserved in raw output and rendered as readable JSON text instead of being dropped. OpenAI and OpenAI-compatible requests recursively sanitize unsupported regex lookaround constraints from model-facing MCP tool schemas while preserving server-side validation. | Full | A future content type can only be native when ACP defines a matching content block; the lossless text/raw fallback remains available otherwise. | | Permissions | Permission requests carry the originating tool-call ID, raw input, normalized locations, and preflight full-file diffs for `edit`, `write`, `write_files`, and multi-file `apply_patch`, with allow once, always allow for this session, and reject choices. Session grants are shared across turns and cover later accesses within the approved folder scope. Patch previews use the same hunk matching without mutating disk. | Full | If an invalid patch cannot be simulated, the request still shows its raw patch and target locations and remains blocked until the user decides. | | Cancellation | `session/cancel` interrupts model turns, questions, compaction, and terminal creation/execution while keeping the session reusable. Crabcode maps completion, output limit, configured turn limit, refusal/content filtering, and cancellation to ACP `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. | Full | Provider failures that are not normal stop conditions remain JSON-RPC/tool errors, as required by ACP's stop-reason model. | | Commands and skills | Session updates publish global/workspace skills, project custom commands, and `/skills`, `/mcp`, `/compact`, and `/btw`. Commands are dispatched from regular ACP text prompts as required by the protocol. Custom command agent/model overrides apply to that turn while embedded resources and media remain attached. `/skills` and `/mcp` return local results without spending or persisting a model turn; `/mcp` reports live connection/auth/failure status. `/compact` rewrites persisted context, while OpenCode-compatible `compaction.auto`, `compaction.prune`, and `compaction.reserved` also apply to normal ACP turns. `/btw` runs a no-tools side question without changing the main transcript. Unknown slash commands return an explicit error. | Full | Editor-native session/model/mode operations replace TUI-only navigation dialogs and pickers rather than duplicating their terminal UI commands. | diff --git a/src/aisdk/providers/compatible.rs b/src/aisdk/providers/compatible.rs index 905aebfa..d16f5cd1 100644 --- a/src/aisdk/providers/compatible.rs +++ b/src/aisdk/providers/compatible.rs @@ -141,7 +141,7 @@ impl Provider for OpenAICompatible { for t in tools { match &t.transport { crate::aisdk::tool::ToolTransport::ClientFunction => { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let schema = crate::tool::openai_compatible_input_schema(&t.input_schema); tool_params.push(serde_json::json!({ "type": "function", "function": { diff --git a/src/aisdk/providers/openai.rs b/src/aisdk/providers/openai.rs index cfbff584..960a66b5 100644 --- a/src/aisdk/providers/openai.rs +++ b/src/aisdk/providers/openai.rs @@ -620,7 +620,7 @@ impl OpenAI { // Plugins are not Responses `tools` entries. } crate::aisdk::tool::ToolTransport::ClientFunction => { - let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let schema = crate::tool::openai_compatible_input_schema(&t.input_schema); let mut tool = serde_json::json!({ "type": "function", "name": t.name, @@ -3281,6 +3281,47 @@ mod tests { assert_eq!(body["tools"].as_array().map(Vec::len), Some(1)); } + #[test] + fn responses_body_removes_unsupported_tool_regex_lookaround() { + let provider = OpenAI::builder() + .base_url("https://api.openai.com") + .api_key("test-key") + .model_name("gpt-test") + .build() + .unwrap(); + let schema: Schema = serde_json::from_value(serde_json::json!({ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "email": { + "type": "string", + "pattern": "^(?!\\.)(?!.*\\.\\.)[^@]+@[^@]+$" + } + } + } + } + })) + .unwrap(); + let tools = vec![Tool::builder() + .name("contact_create") + .description("Create a contact") + .input_schema(schema) + .execute(ToolExecute::new(|_| async { Ok("ok") })) + .build() + .unwrap()]; + + let body = provider.build_responses_body( + vec![serde_json::json!({"role": "user", "content": "create contact"})], + &tools, + ); + + assert!(body + .pointer("/tools/0/parameters/properties/data/properties/email/pattern") + .is_none()); + } + #[test] fn responses_lite_uses_input_items_and_lite_reasoning_contract() { let provider = OpenAI::builder() diff --git a/src/aisdk/tool.rs b/src/aisdk/tool.rs index ed60b839..cdfd12e7 100644 --- a/src/aisdk/tool.rs +++ b/src/aisdk/tool.rs @@ -16,6 +16,193 @@ pub struct ToolOutput { pub images: Vec, } +#[cfg(test)] +mod tests { + use super::openai_compatible_input_schema; + use schemars::Schema; + use serde_json::json; + + fn schema(value: serde_json::Value) -> Schema { + serde_json::from_value(value).expect("valid schema") + } + + #[test] + fn strips_nested_regex_lookaround_from_openai_tool_schema() { + let input = schema(json!({ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Contact email.", + "pattern": "^(?!\\.)(?!.*\\.\\.)[^@]+@[^@]+$" + } + } + }, + "slug": { "type": "string", "pattern": "^[a-z0-9-]+$" } + } + })); + + let sanitized = openai_compatible_input_schema(&input); + assert!(sanitized + .pointer("/properties/data/properties/email/pattern") + .is_none()); + assert_eq!( + sanitized.pointer("/properties/data/properties/email/description"), + Some(&json!( + "Contact email. Validation for this value is enforced by the tool." + )) + ); + assert_eq!( + sanitized.pointer("/properties/slug/pattern"), + Some(&json!("^[a-z0-9-]+$")) + ); + } + + #[test] + fn strips_all_lookaround_forms_from_schema_branches() { + for pattern in ["a(?=b)", "a(?!b)", "(?<=a)b", "(? serde_json::Value { + let mut schema = serde_json::to_value(schema).unwrap_or_default(); + sanitize_openai_schema_node(&mut schema); + schema +} + +fn sanitize_openai_schema_node(node: &mut serde_json::Value) { + match node { + serde_json::Value::Object(object) => { + if object + .get("pattern") + .and_then(serde_json::Value::as_str) + .is_some_and(contains_regex_lookaround) + { + object.remove("pattern"); + const NOTE: &str = "Validation for this value is enforced by the tool."; + let description = object + .get("description") + .and_then(serde_json::Value::as_str) + .map(|description| format!("{description} {NOTE}")) + .unwrap_or_else(|| NOTE.to_string()); + object.insert( + "description".to_string(), + serde_json::Value::String(description), + ); + } + + // Visit schema-valued keywords only. Literal values under `const`, + // `default`, `enum`, `examples`, or extension metadata must remain + // untouched even when they contain a property named `pattern`. + for (keyword, value) in object.iter_mut() { + match keyword.as_str() { + "properties" | "patternProperties" | "$defs" | "definitions" + | "dependentSchemas" | "dependencies" => { + if let Some(schemas) = value.as_object_mut() { + for schema in schemas.values_mut() { + sanitize_openai_schema_node(schema); + } + } + } + "items" + | "additionalItems" + | "additionalProperties" + | "contains" + | "propertyNames" + | "not" + | "if" + | "then" + | "else" + | "unevaluatedProperties" + | "unevaluatedItems" + | "contentSchema" + | "allOf" + | "anyOf" + | "oneOf" + | "prefixItems" => sanitize_openai_schema_node(value), + _ => {} + } + } + } + serde_json::Value::Array(items) => { + for item in items { + sanitize_openai_schema_node(item); + } + } + _ => {} + } +} + +fn contains_regex_lookaround(pattern: &str) -> bool { + let bytes = pattern.as_bytes(); + let mut escaped = false; + let mut in_character_class = false; + let mut index = 0; + + while index < bytes.len() { + let byte = bytes[index]; + if escaped { + escaped = false; + index += 1; + continue; + } + match byte { + b'\\' => escaped = true, + b'[' if !in_character_class => in_character_class = true, + b']' if in_character_class => in_character_class = false, + b'(' if !in_character_class && bytes.get(index + 1) == Some(&b'?') => { + let forward = matches!(bytes.get(index + 2), Some(b'=') | Some(b'!')); + let backward = matches!( + (bytes.get(index + 2), bytes.get(index + 3)), + (Some(b'<'), Some(b'=' | b'!')) + ); + if forward || backward { + return true; + } + } + _ => {} + } + index += 1; + } + false +} + impl ToolOutput { pub fn new(text: impl Into) -> Self { Self {