diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15db74a..9325a66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,16 +39,16 @@ jobs: components: rustfmt - run: cargo fmt --all -- --check - # clippy: - # name: Clippy - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # components: clippy - # - uses: Swatinem/rust-cache@v2 - # - run: cargo clippy --workspace --all-targets -- -D warnings + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings doc: name: Doc diff --git a/.gitignore b/.gitignore index 0592392..53fdfbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .DS_Store +.claude diff --git a/AGENTS.md b/AGENTS.md index c5882c6..3aa5b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ This repository is a Rust-based coding agent, currently called `devo`. - When running Rust-related commands (e.g., `just fix` or `cargo test`), allow them to complete without interruption, do not try to kill them using the PID. Slow execution due to Rust’s locking behavior is expected. - Agent can not apply patch to a file more than 800 lines, cause windows patch length limit, agent would failed to apply patch. - Do not introduce trivial wrapper functions — call the underlying function directly unless reuse or abstraction is clearly justified. +- Exclude the target folder when using glob or grep, since it contains a large number of Rust build artifacts. ## Tests diff --git a/Cargo.lock b/Cargo.lock index eb2ba8f..da4de0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "devo-arg0" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "devo-cli" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "devo-client" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "devo-protocol", @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "devo-core" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "devo-file-search" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -691,7 +691,7 @@ dependencies = [ [[package]] name = "devo-mcp" -version = "0.1.4" +version = "0.1.5" dependencies = [ "async-trait", "chrono", @@ -705,7 +705,7 @@ dependencies = [ [[package]] name = "devo-protocol" -version = "0.1.4" +version = "0.1.5" dependencies = [ "chrono", "pretty_assertions", @@ -721,7 +721,7 @@ dependencies = [ [[package]] name = "devo-provider" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-stream", @@ -741,7 +741,7 @@ dependencies = [ [[package]] name = "devo-safety" -version = "0.1.4" +version = "0.1.5" dependencies = [ "async-trait", "regex", @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "devo-server" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", @@ -784,7 +784,7 @@ dependencies = [ [[package]] name = "devo-tasks" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "devo-tools" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", @@ -808,24 +808,30 @@ dependencies = [ "devo-protocol", "devo-provider", "devo-safety", + "filedescriptor", "futures", "glob", + "lazy_static", "portable-pty", "pretty_assertions", + "rand", "regex", "reqwest", "serde", "serde_json", + "shared_library", + "shlex", "smol_str", "thiserror 2.0.18", "tokio", "tracing", "uuid", + "winapi", ] [[package]] name = "devo-tui" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "arboard", @@ -881,7 +887,7 @@ dependencies = [ [[package]] name = "devo-utils" -version = "0.1.4" +version = "0.1.5" dependencies = [ "ansi-to-tui", "anyhow", diff --git a/crates/cli/src/agent_command.rs b/crates/cli/src/agent_command.rs index 5775b17..448331e 100644 --- a/crates/cli/src/agent_command.rs +++ b/crates/cli/src/agent_command.rs @@ -1,11 +1,15 @@ use anyhow::Context; use anyhow::Result; +use devo_core::AppConfigLoader; +use devo_core::FileSystemAppConfigLoader; use devo_core::ModelCatalog; use devo_core::PresetModelCatalog; use devo_core::ProviderConfigFile; use devo_core::ResolvedProviderSettings; use devo_core::load_config; +use devo_core::project_config_key; use devo_core::resolve_provider_settings; +use devo_protocol::PermissionPreset; use devo_protocol::ProviderWireApi; use devo_tui::InitialTuiSession; use devo_tui::InteractiveTuiConfig; @@ -24,6 +28,13 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) - let config_home = find_devo_home().context("could not determine devo home directory")?; let model_catalog = PresetModelCatalog::load_from_config(&config_home, Some(&cwd))?; let stored_config = load_config().unwrap_or_default(); + let app_config = FileSystemAppConfigLoader::new(config_home.clone()).load(Some(&cwd))?; + let project_key = project_config_key(&cwd); + let permission_preset = app_config + .projects + .get(&project_key) + .and_then(|config| config.permission_preset) + .unwrap_or(PermissionPreset::Default); let (onboarding_mode, resolved) = resolve_initial_provider_settings(force_onboarding, &stored_config, &model_catalog)?; @@ -47,6 +58,7 @@ pub(crate) async fn run_agent(force_onboarding: bool, log_level: Option<&str>) - model, provider: wire_api, thinking_selection: model_thinking_selection, + permission_preset, // TODO: why do we need cwd here, maybe remove it ? cwd, }, diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index 3776a44..62400a7 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -19,7 +19,6 @@ pub(crate) async fn run_prompt( use devo_core::SessionConfig; use devo_core::SessionState; use devo_core::default_base_instructions; - use devo_tools::ToolRegistry; use devo_tools::ToolRuntime; let cwd = std::env::current_dir()?; @@ -48,8 +47,7 @@ pub(crate) async fn run_prompt( session_state.push_message(devo_core::Message::user(input.to_string())); let registry = { - let mut reg = ToolRegistry::new(); - devo_tools::register_builtin_tools(&mut reg); + let reg = devo_tools::create_default_tool_registry(); std::sync::Arc::new(reg) }; let runtime = ToolRuntime::new_without_permissions(std::sync::Arc::clone(®istry)); diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 1c02fe7..65bbced 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -7,6 +7,7 @@ use std::sync::atomic::Ordering; use anyhow::Context; use anyhow::Result; +use devo_protocol::ApprovalRespondParams; use devo_protocol::ClientNotification; use devo_protocol::ClientRequest; use devo_protocol::ClientTransportKind; @@ -28,6 +29,8 @@ use devo_protocol::SessionListParams; use devo_protocol::SessionListResult; use devo_protocol::SessionMetadataUpdateParams; use devo_protocol::SessionMetadataUpdateResult; +use devo_protocol::SessionPermissionsUpdateParams; +use devo_protocol::SessionPermissionsUpdateResult; use devo_protocol::SessionResumeParams; use devo_protocol::SessionResumeResult; use devo_protocol::SessionRollbackParams; @@ -184,6 +187,13 @@ impl StdioServerClient { self.request("session/metadata/update", params).await } + pub async fn session_permissions_update( + &mut self, + params: SessionPermissionsUpdateParams, + ) -> Result { + self.request("session/permissions/update", params).await + } + pub async fn session_compact( &mut self, params: SessionCompactParams, @@ -239,6 +249,11 @@ impl StdioServerClient { self.request("turn/steer", params).await } + pub async fn approval_respond(&mut self, params: ApprovalRespondParams) -> Result<()> { + let _: serde_json::Value = self.request("approval/respond", params).await?; + Ok(()) + } + pub async fn recv_notification(&mut self) -> Option { self.notifications_rx.recv().await } diff --git a/crates/core/src/config/app.rs b/crates/core/src/config/app.rs index 80a0de4..0b3d200 100644 --- a/crates/core/src/config/app.rs +++ b/crates/core/src/config/app.rs @@ -1,10 +1,13 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; +use devo_protocol::PermissionPreset; use serde::{Deserialize, Serialize}; use devo_utils::FileSystemConfigPathResolver; +use devo_utils::git_op::get_git_repo_root; use crate::AgentsMdConfig; use crate::SkillsConfig; @@ -35,6 +38,15 @@ pub struct AppConfig { /// TODO: Not sure what's purpose of `project_root_markers`? /// Marker names used when discovering a project root. pub project_root_markers: Vec, + /// User-level settings remembered per project key. + pub projects: BTreeMap, +} + +/// Settings remembered for one project. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ProjectConfig { + /// Permission preset to use when starting new sessions for this project. + pub permission_preset: Option, } /// Controls how the CLI checks for new releases at startup. @@ -118,6 +130,7 @@ impl Default for AppConfig { check_interval_hours: 24, }, project_root_markers: vec![".git".into()], + projects: BTreeMap::new(), } } } @@ -131,6 +144,28 @@ impl AppConfig { } } +/// Returns the stable key used to remember project-level permission settings. +/// +/// Git repositories are keyed by their repository root. Non-git directories fall +/// back to the canonical current working directory when possible. +pub fn project_config_key(cwd: &Path) -> String { + let root = get_git_repo_root(cwd) + .or_else(|| cwd.canonicalize().ok()) + .unwrap_or_else(|| cwd.to_path_buf()); + strip_unc_prefix(root).display().to_string() +} + +fn strip_unc_prefix(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + let value = path.display().to_string(); + if let Some(stripped) = value.strip_prefix("\\\\?\\") { + return PathBuf::from(stripped); + } + } + path +} + fn read_config_value(path: &Path) -> Result { let contents = fs::read_to_string(path).map_err(|source| AppConfigError::Io { path: path.to_path_buf(), diff --git a/crates/core/src/config/tests.rs b/crates/core/src/config/tests.rs index c03e115..641dc08 100644 --- a/crates/core/src/config/tests.rs +++ b/crates/core/src/config/tests.rs @@ -1,11 +1,13 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +use devo_protocol::PermissionPreset; use pretty_assertions::assert_eq; use super::{ AppConfig, AppConfigLoader, ContextManageConfig, FileSystemAppConfigLoader, LogRotation, - LoggingConfig, SafetyPolicyModelSelection, SummaryModelSelection, UpdatesConfig, + LoggingConfig, ProjectConfig, SafetyPolicyModelSelection, SummaryModelSelection, UpdatesConfig, }; use crate::SkillsConfig; @@ -110,6 +112,7 @@ check_interval_hours = 48 check_interval_hours: 48, }, project_root_markers: vec![".workspace".into()], + projects: BTreeMap::new(), } ); @@ -160,6 +163,33 @@ fn loader_rejects_duplicate_skill_roots() { let _ = std::fs::remove_dir_all(root); } +#[test] +fn loader_reads_project_configs() { + let root = unique_temp_dir("config-projects"); + let home = root.join("home").join(".devo"); + std::fs::create_dir_all(&home).expect("home config dir"); + std::fs::write( + home.join("config.toml"), + "[projects.\"C:\\\\repo\"]\npermission_preset = 'read-only'\n", + ) + .expect("write user config"); + + let loader = FileSystemAppConfigLoader::new(home); + let config = loader.load(None).expect("load config"); + + assert_eq!( + config.projects, + BTreeMap::from([( + "C:\\repo".to_string(), + ProjectConfig { + permission_preset: Some(PermissionPreset::ReadOnly), + }, + )]) + ); + + let _ = std::fs::remove_dir_all(root); +} + #[test] fn default_app_config_enables_startup_update_checks() { assert_eq!( diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index 4826671..7f799e2 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -2,7 +2,8 @@ mod records; pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus, TurnUsage}; pub use records::{ - ApprovalDecisionItem, ApprovalRequestItem, CompactionSnapshotLine, ItemLine, ItemRecord, - RolloutLine, SessionMetaLine, SessionRecord, SessionTitleUpdatedLine, TextItem, ToolCallItem, - ToolProgressItem, ToolResultItem, TurnError, TurnItem, TurnLine, TurnRecord, Worklog, + ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, + ItemLine, ItemRecord, RolloutLine, SessionMetaLine, SessionRecord, SessionTitleUpdatedLine, + TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, TurnItem, TurnLine, + TurnRecord, Worklog, }; diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index c3bcb56..7f17fe3 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -134,7 +134,17 @@ pub struct ToolProgressItem { pub message: String, } -/// Stores one terminal tool result as a persisted item payload. +/// Stores one tool result as a persisted item payload. +/// +/// This is the generic result type used for all tools *except* `exec_command` and +/// `write_stdin`. Those two tools use [`CommandExecutionItem`] instead, because +/// they carry extra data (the display command, the original model input for +/// prompt replay) that does not apply to other tools. +/// +/// The two types exist side by side — rather than a single type with optional +/// command fields — so that downstream code can match on the [`TurnItem`] enum +/// and immediately know whether it is dealing with a terminal command or a +/// generic tool result, without inspecting optional fields. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolResultItem { /// The tool call this result belongs to. @@ -147,6 +157,40 @@ pub struct ToolResultItem { pub is_error: bool, } +/// Stores one unified command execution as a persisted item payload. +/// +/// This is a specialised result type for `exec_command` and `write_stdin`. +/// It exists as a separate [`TurnItem`] variant (rather than reusing +/// [`ToolResultItem`]) for two reasons: +/// +/// 1. **Display** — the `command` field holds the human-readable shell command +/// (e.g. `"nc 127.0.0.1 4444"`) so the UI can render it as a terminal +/// interaction instead of a generic tool result. +/// +/// 2. **Prompt replay** — the `input` field preserves the exact model-supplied +/// JSON input that triggered the command. During compaction and context +/// reconstruction the runtime needs this to rebuild a faithful prompt, +/// and it is not stored on [`ToolCallItem`] in a form that survives +/// compaction cleanly. +/// +/// Every other tool uses [`ToolResultItem`] instead. See the doc comment on +/// that struct for the trade-off rationale. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandExecutionItem { + /// The tool call this command execution belongs to. + pub tool_call_id: String, + /// The runtime tool name, usually `exec_command` or `write_stdin`. + pub tool_name: String, + /// The display command or terminal interaction text. + pub command: String, + /// Original model input for prompt replay. + pub input: serde_json::Value, + /// Normalized tool output returned by the command. + pub output: serde_json::Value, + /// Whether the result represents an error outcome. + pub is_error: bool, +} + /// Stores one approval request as a persisted item payload. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApprovalRequestItem { @@ -156,6 +200,21 @@ pub struct ApprovalRequestItem { pub action_summary: String, /// The justification shown to the user. pub justification: String, + /// The resource kind this approval gates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + /// Scope choices offered to the user. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub available_scopes: Vec, + /// Optional path related to the request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Optional host related to the request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Optional command, URL, query, or other target string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, } /// Stores one approval decision as a persisted item payload. @@ -188,8 +247,12 @@ pub enum TurnItem { ToolCall(ToolCallItem), /// A tool-progress item. ToolProgress(ToolProgressItem), - /// A terminal tool-result item. + /// A terminal tool-result item (every tool *except* exec_command / write_stdin). ToolResult(ToolResultItem), + /// A unified command execution item (only exec_command and write_stdin). + /// Carries extra fields for display (the human-readable command) and prompt + /// replay (the original model input). See [`CommandExecutionItem`]. + CommandExecution(CommandExecutionItem), /// An approval-request item. ApprovalRequest(ApprovalRequestItem), /// An approval-decision item. diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index d7c91d2..6b740ed 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -67,6 +67,8 @@ pub enum QueryEvent { TextDelta(String), /// Incremental reasoning text from the assistant. ReasoningDelta(String), + /// Current reasoning block completed. + ReasoningCompleted, /// Incremental token usage update from the provider stream. /// TODO: Review the mechanism from the OpenAI API / Anthropic API documentation. UsageDelta { @@ -610,6 +612,9 @@ pub async fn query( reasoning_text.push_str(&text); emit(QueryEvent::ReasoningDelta(text)); } + Ok(StreamEvent::ReasoningDone { .. }) => { + emit(QueryEvent::ReasoningCompleted); + } Ok(StreamEvent::ToolCallStart { id, name, input, .. }) => { @@ -933,7 +938,7 @@ mod tests { use devo_protocol::StreamEvent; use devo_protocol::Usage; use devo_provider::ModelProviderSDK; - use devo_safety::legacy_permissions::PermissionMode; + use devo_safety::PermissionMode; use devo_tools::ToolRegistry; use devo_tools::ToolRuntime; use devo_tools::errors::ToolExecutionError; @@ -1272,8 +1277,8 @@ mod tests { supports_parallel: false, }); let registry = Arc::new(builder.build()); - let deny_checker = PermissionChecker::new(|name| { - let n = name.to_string(); + let deny_checker = PermissionChecker::new(|request| { + let n = request.tool_name; Box::pin(async move { Err(format!("{n} denied")) }) }); let runtime = ToolRuntime::new(Arc::clone(®istry), deny_checker); diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index cbb3566..9ce5ce1 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -3,7 +3,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; -use devo_safety::legacy_permissions::PermissionMode; +use devo_safety::PermissionMode; +use devo_safety::PermissionPreset; +use devo_safety::RuntimePermissionProfile; use devo_protocol::PendingInputItem; use devo_protocol::TurnKind; @@ -21,14 +23,19 @@ use crate::state::turn::TurnState; pub struct SessionConfig { pub token_budget: TokenBudget, pub permission_mode: PermissionMode, + pub permission_profile: RuntimePermissionProfile, pub agents_md: AgentsMdConfig, } impl Default for SessionConfig { fn default() -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let permission_profile = + RuntimePermissionProfile::from_preset(PermissionPreset::Default, cwd); Self { token_budget: TokenBudget::default(), - permission_mode: PermissionMode::AutoApprove, + permission_mode: permission_profile.permission_mode(), + permission_profile, agents_md: AgentsMdConfig::default(), } } diff --git a/crates/protocol/src/approval.rs b/crates/protocol/src/approval.rs index 45c8c91..25fe126 100644 --- a/crates/protocol/src/approval.rs +++ b/crates/protocol/src/approval.rs @@ -32,6 +32,7 @@ pub enum ApprovalScopeValue { PathPrefix, Host, Tool, + CommandPrefix, } /// Describes the payload for `events/subscribe`. diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index d270477..25931a7 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -37,6 +37,17 @@ pub struct ToolResultPayload { pub summary: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandExecutionPayload { + pub tool_call_id: String, + pub tool_name: String, + pub command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default)] + pub is_error: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ItemEventPayload { pub context: EventContext, @@ -160,6 +171,23 @@ pub struct ApprovalRequestPayload { pub approval_id: SmolStr, pub action_summary: String, pub justification: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub available_scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApprovalDecisionPayload { + pub approval_id: SmolStr, + pub decision: String, + pub scope: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 2c731c9..1fcb548 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -4,6 +4,7 @@ mod conversation; mod event; mod model; pub mod parse_command; +mod permissions; pub mod protocol; mod response; mod role; @@ -19,6 +20,7 @@ pub use connection::*; pub use conversation::*; pub use event::*; pub use model::*; +pub use permissions::*; pub use protocol::*; pub use response::*; pub use role::*; diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index d2bf1df..a3eda19 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -82,6 +82,8 @@ pub struct ToolDefinition { pub name: String, pub description: String, pub input_schema: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, } /// A content block within a message sent to the model. @@ -802,6 +804,7 @@ mod tests { name: "bash".into(), description: "run commands".into(), input_schema: json!({"type": "object", "properties": {"cmd": {"type": "string"}}}), + output_schema: None, }; let json = serde_json::to_string(&def).unwrap(); let deserialized: ToolDefinition = serde_json::from_str(&json).unwrap(); diff --git a/crates/protocol/src/permissions.rs b/crates/protocol/src/permissions.rs new file mode 100644 index 0000000..a99fdce --- /dev/null +++ b/crates/protocol/src/permissions.rs @@ -0,0 +1,36 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::SessionId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum PermissionPreset { + ReadOnly, + #[default] + Default, + AutoReview, + FullAccess, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalsReviewer { + #[default] + User, + AutoReview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPermissionsUpdateParams { + pub session_id: SessionId, + pub preset: PermissionPreset, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPermissionsUpdateResult { + pub session_id: SessionId, + pub preset: PermissionPreset, + pub reviewer: ApprovalsReviewer, +} diff --git a/crates/protocol/src/response.rs b/crates/protocol/src/response.rs index 448f08c..073543e 100644 --- a/crates/protocol/src/response.rs +++ b/crates/protocol/src/response.rs @@ -76,6 +76,8 @@ pub enum StreamEvent { ReasoningStart { index: usize }, /// Incremental reasoning delta. ReasoningDelta { index: usize, text: String }, + /// End of a reasoning block. + ReasoningDone { index: usize }, /// A tool call started. ToolCallStart { index: usize, diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index d4d5813..510c07f 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -84,6 +84,7 @@ pub enum SessionHistoryItemKind { Reasoning, ToolCall, ToolResult, + CommandExecution, Error, TurnSummary, } diff --git a/crates/provider/src/anthropic/messages.rs b/crates/provider/src/anthropic/messages.rs index 35e0349..2ec462d 100644 --- a/crates/provider/src/anthropic/messages.rs +++ b/crates/provider/src/anthropic/messages.rs @@ -998,6 +998,7 @@ mod tests { "properties": { "city": { "type": "string" } }, "required": ["city"] }), + output_schema: None, }]), sampling: SamplingControls { temperature: Some(0.2), diff --git a/crates/provider/src/openai/chat_completions.rs b/crates/provider/src/openai/chat_completions.rs index 17f39d3..19938d4 100644 --- a/crates/provider/src/openai/chat_completions.rs +++ b/crates/provider/src/openai/chat_completions.rs @@ -1192,6 +1192,7 @@ mod tests { "properties": { "city": { "type": "string" } }, "required": ["city"] }), + output_schema: None, }]), sampling: SamplingControls { temperature: Some(0.2), diff --git a/crates/provider/src/openai/chat_completions/stream.rs b/crates/provider/src/openai/chat_completions/stream.rs index 9e2cd50..b1efd7a 100644 --- a/crates/provider/src/openai/chat_completions/stream.rs +++ b/crates/provider/src/openai/chat_completions/stream.rs @@ -104,6 +104,12 @@ pub(super) async fn completion_stream( match event { Event::Open => {} Event::Message(message) => { + tracing::trace!( + event = %message.event, + data_len = message.data.len(), + data = %message.data, + "openai chat completions raw stream event" + ); if message.data == "[DONE]" { break; } @@ -139,6 +145,7 @@ struct ChatCompletionStreamState { text: StreamTextBlock, text_parser: TaggedTextParser, reasoning: StreamTextBlock, + reasoning_content_active: bool, refusal: String, role: Option, tool_calls: BTreeMap, @@ -199,12 +206,18 @@ impl ChatCompletionStreamState { self.refusal.push_str(&refusal); } - if let Some(reasoning_content) = choice - .delta - .reasoning_content - .filter(|reasoning_content| !reasoning_content.is_empty()) - { - self.push_reasoning_delta(reasoning_content, events); + match choice.delta.reasoning_content { + Some(reasoning_content) => { + self.reasoning_content_active = true; + if !reasoning_content.is_empty() { + self.push_reasoning_delta(reasoning_content, events); + } + } + None if self.reasoning_content_active => { + self.reasoning_content_active = false; + self.push_reasoning_done(events); + } + None => {} } if let Some(content) = choice.delta.content.filter(|content| !content.is_empty()) { @@ -259,6 +272,12 @@ impl ChatCompletionStreamState { events.push(StreamEvent::ReasoningDelta { index: 1, text }); } + fn push_reasoning_done(&mut self, events: &mut Vec) { + if self.reasoning.started { + events.push(StreamEvent::ReasoningDone { index: 1 }); + } + } + fn apply_tool_call_delta( &mut self, tool_call_delta: ChatCompletionStreamToolCallDelta, @@ -1073,4 +1092,142 @@ mod tests { && payload["choices"][0]["index"] == json!(0) ))); } + + #[test] + fn reasoning_content_phase_done_before_tool_call_when_field_disappears() { + let mut state = ChatCompletionStreamState::default(); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "role": "assistant", + "content": null, + "reasoning_content": "" + }, + "finish_reason": null + } + ] + }))); + assert!(events.is_empty()); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "content": null, + "reasoning_content": " wants" + }, + "finish_reason": null + } + ] + }))); + assert_eq!(events.len(), 2); + assert!(matches!( + &events[0], + StreamEvent::ReasoningStart { index: 1 } + )); + assert!(matches!( + &events[1], + StreamEvent::ReasoningDelta { index: 1, text } if text == " wants" + )); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "content": null, + "reasoning_content": "." + }, + "finish_reason": null + } + ] + }))); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + StreamEvent::ReasoningDelta { index: 1, text } if text == "." + )); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_00_600mpUqdusY9jkJm31MM0811", + "type": "function", + "function": { + "name": "read", + "arguments": "" + } + } + ] + }, + "finish_reason": null + } + ] + }))); + assert_eq!(events.len(), 2); + assert!(matches!( + &events[0], + StreamEvent::ReasoningDone { index: 1 } + )); + assert!(matches!( + &events[1], + StreamEvent::ToolCallStart { index: 1, id, name, .. } + if id == "call_00_600mpUqdusY9jkJm31MM0811" && name == "read" + )); + } + + #[test] + fn reasoning_content_null_ends_active_reasoning_phase_once() { + let mut state = ChatCompletionStreamState::default(); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "reasoning_content": "plan" + }, + "finish_reason": null + } + ] + }))); + assert_eq!(events.len(), 2); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "reasoning_content": null + }, + "finish_reason": null + } + ] + }))); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + StreamEvent::ReasoningDone { index: 1 } + )); + + let events = state.apply_chunk(parse_chunk(json!({ + "choices": [ + { + "delta": { + "content": "answer" + }, + "finish_reason": "stop" + } + ] + }))); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], StreamEvent::TextStart { index: 0 })); + assert!(matches!( + &events[1], + StreamEvent::TextDelta { index: 0, text } if text == "answer" + )); + } } diff --git a/crates/provider/src/openai/responses.rs b/crates/provider/src/openai/responses.rs index ffe4e1f..158ee46 100644 --- a/crates/provider/src/openai/responses.rs +++ b/crates/provider/src/openai/responses.rs @@ -671,6 +671,7 @@ mod tests { name: "get_weather".to_string(), description: "Get weather by city".to_string(), input_schema: json!({"type": "object"}), + output_schema: None, }]), sampling: SamplingControls { temperature: Some(0.4), diff --git a/crates/provider/src/openai/shared.rs b/crates/provider/src/openai/shared.rs index 1f80009..b6b4e9a 100644 --- a/crates/provider/src/openai/shared.rs +++ b/crates/provider/src/openai/shared.rs @@ -81,13 +81,17 @@ pub(crate) fn tool_definitions(tools: &[ToolDefinition]) -> Value { tools .iter() .map(|tool| { + let mut function = json!({ + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }); + if let Some(output_schema) = &tool.output_schema { + function["output_schema"] = output_schema.clone(); + } json!({ "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.input_schema, - } + "function": function }) }) .collect(), diff --git a/crates/safety/src/legacy_permissions.rs b/crates/safety/src/legacy_permissions.rs deleted file mode 100644 index cfb096a..0000000 --- a/crates/safety/src/legacy_permissions.rs +++ /dev/null @@ -1,213 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -/// The legacy permission mode controlling how the current runtime handles permission checks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PermissionMode { - /// Approve every request without asking. - AutoApprove, - /// Ask the user for confirmation on each request. - Interactive, - /// Deny all requests that require permission. - Deny, -} - -/// The legacy resource kind used by the current tool runtime. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ResourceKind { - /// A file-read request. - FileRead, - /// A file-write request. - FileWrite, - /// A shell-execution request. - ShellExec, - /// A network-access request. - Network, - /// A tool-specific custom resource kind. - Custom(String), -} - -/// The legacy permission request emitted by the current tool system. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionRequest { - /// The originating tool name. - pub tool_name: String, - /// The kind of resource being accessed. - pub resource: ResourceKind, - /// The free-form human-readable description of the action. - pub description: String, - /// The optional target path, host, or command string. - pub target: Option, -} - -/// The legacy result of one permission check. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum PermissionDecision { - /// Allow the request immediately. - Allow, - /// Deny the request with a reason. - Deny { - /// The human-readable denial reason. - reason: String, - }, - /// Ask the user to approve the request. - Ask { - /// The human-readable approval prompt. - message: String, - }, -} - -/// The legacy pluggable permission-policy trait used by the current runtime. -#[async_trait] -pub trait PermissionPolicy: Send + Sync { - /// Returns the legacy permission decision for one request. - async fn check(&self, request: &PermissionRequest) -> PermissionDecision; -} - -/// One legacy rule-based permission entry persisted in configuration or tests. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionRule { - /// The resource kind matched by the rule. - pub resource: ResourceKind, - /// The glob-like pattern matched against the target. - pub pattern: String, - /// Whether the rule allows or denies matching requests. - pub allow: bool, -} - -/// The legacy rule-based permission policy used by the current query loop and tools. -pub struct RuleBasedPolicy { - /// The fallback permission mode used when no explicit rule matches. - pub mode: PermissionMode, - /// The explicit resource rules evaluated before the fallback mode. - pub rules: Vec, -} - -impl RuleBasedPolicy { - /// Creates a rule-based policy with no explicit rules. - pub fn new(mode: PermissionMode) -> Self { - Self { - mode, - rules: Vec::new(), - } - } - - /// Creates a rule-based policy with an explicit rule list. - pub fn with_rules(mode: PermissionMode, rules: Vec) -> Self { - Self { mode, rules } - } - - fn match_rule(&self, request: &PermissionRequest) -> Option<&PermissionRule> { - let target = request.target.as_deref().unwrap_or(""); - self.rules.iter().find(|rule| { - rule.resource == request.resource && Self::pattern_matches(&rule.pattern, target) - }) - } - - fn pattern_matches(pattern: &str, target: &str) -> bool { - if pattern == "*" { - return true; - } - if pattern.ends_with('*') { - return target.starts_with(pattern.trim_end_matches('*')); - } - target == pattern - } -} - -#[async_trait] -impl PermissionPolicy for RuleBasedPolicy { - async fn check(&self, request: &PermissionRequest) -> PermissionDecision { - if let Some(rule) = self.match_rule(request) { - return if rule.allow { - PermissionDecision::Allow - } else { - PermissionDecision::Deny { - reason: format!("blocked by rule: {}", rule.pattern), - } - }; - } - - match self.mode { - PermissionMode::AutoApprove => PermissionDecision::Allow, - PermissionMode::Deny => PermissionDecision::Deny { - reason: "permission mode is Deny".into(), - }, - PermissionMode::Interactive => PermissionDecision::Ask { - message: format!( - "{} wants to access {:?}: {}", - request.tool_name, request.resource, request.description - ), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::{ - PermissionDecision, PermissionMode, PermissionPolicy, PermissionRequest, PermissionRule, - ResourceKind, RuleBasedPolicy, - }; - - fn file_write_request(target: Option<&str>) -> PermissionRequest { - PermissionRequest { - tool_name: "file_write".into(), - resource: ResourceKind::FileWrite, - description: "write a file".into(), - target: target.map(|value| value.into()), - } - } - - #[test] - fn permission_mode_serde_roundtrip() { - for mode in [ - PermissionMode::AutoApprove, - PermissionMode::Interactive, - PermissionMode::Deny, - ] { - let json = serde_json::to_string(&mode).expect("serialize"); - let restored: PermissionMode = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(restored, mode); - } - } - - #[test] - fn pattern_matches_prefix_and_exact() { - assert!(RuleBasedPolicy::pattern_matches("/tmp/*", "/tmp/file.txt")); - assert!(RuleBasedPolicy::pattern_matches( - "/etc/passwd", - "/etc/passwd" - )); - assert!(!RuleBasedPolicy::pattern_matches( - "/tmp/*", - "/var/tmp/file.txt" - )); - } - - #[tokio::test] - async fn explicit_allow_rule_overrides_deny_mode() { - let policy = RuleBasedPolicy::with_rules( - PermissionMode::Deny, - vec![PermissionRule { - resource: ResourceKind::FileWrite, - pattern: "/tmp/*".into(), - allow: true, - }], - ); - - assert!(matches!( - policy.check(&file_write_request(Some("/tmp/file"))).await, - PermissionDecision::Allow - )); - } - - #[tokio::test] - async fn interactive_mode_asks() { - let policy = RuleBasedPolicy::new(PermissionMode::Interactive); - assert!(matches!( - policy.check(&file_write_request(Some("/tmp/file"))).await, - PermissionDecision::Ask { .. } - )); - } -} diff --git a/crates/safety/src/lib.rs b/crates/safety/src/lib.rs index ab1d635..6a11ee6 100644 --- a/crates/safety/src/lib.rs +++ b/crates/safety/src/lib.rs @@ -1,14 +1,128 @@ -pub mod legacy_permissions; - -use std::collections::{BTreeSet, HashSet}; -use std::path::{Path, PathBuf}; +use std::collections::BTreeSet; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; use regex::Regex; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; use smol_str::SmolStr; +/// Controls how tool permission requests are handled by the runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionMode { + /// Approve every request without asking. + AutoApprove, + /// Ask the user for confirmation on each request. + Interactive, + /// Deny all requests that require permission. + Deny, +} + +/// User-facing permission presets exposed by `/permissions`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub enum PermissionPreset { + /// Read workspace files without approval; edits, commands, and network ask. + ReadOnly, + /// Read and edit workspace files and run shell commands; network and outside + /// workspace writes ask. + #[default] + Default, + /// Same base policy as default, but eligible approvals may be routed through + /// an automatic reviewer before the user is interrupted. + AutoReview, + /// Allow all tool requests without approval. + FullAccess, +} + +/// Selects who reviews approval requests after policy decides that approval is required. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalsReviewer { + /// Show approval prompts to the user. + #[default] + User, + /// Let the automatic reviewer decide eligible requests first. + AutoReview, +} + +/// Runtime permissions derived from a user-facing permission preset. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePermissionProfile { + pub preset: PermissionPreset, + pub reviewer: ApprovalsReviewer, + pub workspace_root: PathBuf, + pub readable_roots: BTreeSet, + pub writable_roots: BTreeSet, + pub allow_shell_commands: bool, + pub allow_network: bool, + pub auto_approve: bool, +} + +impl RuntimePermissionProfile { + pub fn from_preset( + preset: PermissionPreset, + workspace_root: PathBuf, + ) -> RuntimePermissionProfile { + let mut readable_roots = BTreeSet::new(); + readable_roots.insert(workspace_root.clone()); + let mut writable_roots = BTreeSet::new(); + let reviewer = if matches!(preset, PermissionPreset::AutoReview) { + ApprovalsReviewer::AutoReview + } else { + ApprovalsReviewer::User + }; + + match preset { + PermissionPreset::ReadOnly => RuntimePermissionProfile { + preset, + reviewer, + workspace_root, + readable_roots, + writable_roots, + allow_shell_commands: false, + allow_network: false, + auto_approve: false, + }, + PermissionPreset::Default | PermissionPreset::AutoReview => { + writable_roots.insert(workspace_root.clone()); + RuntimePermissionProfile { + preset, + reviewer, + workspace_root, + readable_roots, + writable_roots, + allow_shell_commands: true, + allow_network: false, + auto_approve: false, + } + } + PermissionPreset::FullAccess => RuntimePermissionProfile { + preset, + reviewer, + workspace_root, + readable_roots, + writable_roots, + allow_shell_commands: true, + allow_network: true, + auto_approve: true, + }, + } + } + + pub fn permission_mode(&self) -> PermissionMode { + if self.auto_approve { + PermissionMode::AutoApprove + } else { + PermissionMode::Interactive + } + } +} + /// The fixed placeholder inserted when a secret is redacted from model-visible text. pub const REDACTED_SECRET_PLACEHOLDER: &str = "[REDACTED_SECRET]"; @@ -348,6 +462,8 @@ pub struct ApprovalCache { pub host_scopes: BTreeSet, /// Canonical path prefixes approved for the whole session. pub path_scopes: BTreeSet, + /// Shell command prefixes approved for the whole session. + pub command_prefix_scopes: BTreeSet>, } /// Describes the declared filesystem policy before approval merging. @@ -708,15 +824,29 @@ mod tests { use regex::Regex; - use super::{ - ApprovalCache, DefaultSandboxPolicyTransformer, EffectiveSandboxPolicy, - FileSystemPolicyRecord, InMemorySecretDetectorRegistry, NetworkPolicy, PermissionDecision, - PermissionProfile, PermissionRequest, PolicyModelSelection, PolicySnapshot, - REDACTED_SECRET_PLACEHOLDER, RegexSecretDetector, ResourceKind, SafetyPolicyMode, - SandboxMode, SandboxPolicyRecord, SecretDetectorRegistry, SecretMatchConfidence, - SecretRedactor, StaticPermissionPolicy, - }; - use crate::{PermissionPolicy, SandboxPolicyTransformer}; + use super::ApprovalCache; + use super::DefaultSandboxPolicyTransformer; + use super::EffectiveSandboxPolicy; + use super::FileSystemPolicyRecord; + use super::InMemorySecretDetectorRegistry; + use super::NetworkPolicy; + use super::PermissionDecision; + use super::PermissionProfile; + use super::PermissionRequest; + use super::PolicyModelSelection; + use super::PolicySnapshot; + use super::REDACTED_SECRET_PLACEHOLDER; + use super::RegexSecretDetector; + use super::ResourceKind; + use super::SafetyPolicyMode; + use super::SandboxMode; + use super::SandboxPolicyRecord; + use super::SecretDetectorRegistry; + use super::SecretMatchConfidence; + use super::SecretRedactor; + use super::StaticPermissionPolicy; + use crate::PermissionPolicy; + use crate::SandboxPolicyTransformer; fn abs_path(suffix: &str) -> PathBuf { #[cfg(windows)] diff --git a/crates/server/src/approval_reviewer.rs b/crates/server/src/approval_reviewer.rs new file mode 100644 index 0000000..0e25d6d --- /dev/null +++ b/crates/server/src/approval_reviewer.rs @@ -0,0 +1,152 @@ +use devo_protocol::{ + ModelRequest, RequestContent, RequestMessage, ResponseContent, SamplingControls, +}; +use devo_tools::ToolPermissionRequest; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ReviewerDecision { + Approve { rationale: String }, + Deny { rationale: String }, + Uncertain { rationale: String }, +} + +pub(crate) fn build_approval_review_request( + model: String, + request: &ToolPermissionRequest, +) -> ModelRequest { + ModelRequest { + model, + system: Some( + "You are Devo's automatic approval reviewer. Decide whether a tool approval request is safe under the user's active policy. Respond with exactly one compact JSON object and no markdown: {\"decision\":\"approve|deny|uncertain\",\"rationale\":\"short reason\"}. Approve only when the action is clearly low risk and scoped to the stated target. Deny destructive, credential, privilege escalation, or ambiguous high-impact actions. Use uncertain when more context or user intent is needed." + .to_string(), + ), + messages: vec![RequestMessage { + role: "user".to_string(), + content: vec![RequestContent::Text { + text: review_prompt_for_request(request), + }], + }], + max_tokens: 128, + tools: None, + sampling: SamplingControls { + temperature: Some(0.0), + ..SamplingControls::default() + }, + thinking: None, + reasoning_effort: None, + extra_body: None, + } +} + +pub(crate) fn parse_reviewer_decision(content: &[ResponseContent]) -> Option { + let raw = content.iter().find_map(|block| match block { + ResponseContent::Text(text) => Some(text.as_str()), + ResponseContent::ToolUse { .. } => None, + })?; + parse_reviewer_text(raw) +} + +fn parse_reviewer_text(raw: &str) -> Option { + let trimmed = raw + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + let value: serde_json::Value = serde_json::from_str(trimmed).ok()?; + let rationale = value + .get("rationale") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + match value.get("decision").and_then(serde_json::Value::as_str)? { + "approve" => Some(ReviewerDecision::Approve { rationale }), + "deny" => Some(ReviewerDecision::Deny { rationale }), + "uncertain" => Some(ReviewerDecision::Uncertain { rationale }), + _ => None, + } +} + +fn review_prompt_for_request(request: &ToolPermissionRequest) -> String { + let mut details = vec![ + format!("tool_name: {}", request.tool_name), + format!("resource: {:?}", request.resource), + format!("cwd: {}", request.cwd.display()), + format!("action_summary: {}", request.action_summary), + ]; + if let Some(justification) = &request.justification { + details.push(format!("justification: {justification}")); + } + if let Some(path) = &request.path { + details.push(format!("path: {}", path.display())); + } + if let Some(host) = &request.host { + details.push(format!("host: {host}")); + } + if let Some(target) = &request.target { + details.push(format!("target: {target}")); + } + if let Some(command_prefix) = &request.command_prefix { + details.push(format!("command_prefix: {}", command_prefix.join(" "))); + } + details.push(format!("input_json: {}", request.input)); + details.join("\n") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn parses_approval_reviewer_json_decision() { + assert_eq!( + parse_reviewer_text(r#"{"decision":"approve","rationale":"scoped command"}"#), + Some(ReviewerDecision::Approve { + rationale: "scoped command".to_string(), + }) + ); + assert_eq!( + parse_reviewer_text(r#"{"decision":"deny","rationale":"destructive"}"#), + Some(ReviewerDecision::Deny { + rationale: "destructive".to_string(), + }) + ); + assert_eq!( + parse_reviewer_text(r#"{"decision":"uncertain","rationale":"needs user"}"#), + Some(ReviewerDecision::Uncertain { + rationale: "needs user".to_string(), + }) + ); + } + + #[test] + fn builds_review_prompt_with_command_prefix() { + let request = ToolPermissionRequest { + tool_call_id: "call".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "git add -A" }), + cwd: std::path::PathBuf::from("C:\\repo"), + session_id: "session".to_string(), + turn_id: Some("turn".to_string()), + resource: devo_safety::ResourceKind::ShellExec, + action_summary: "Run git add -A".to_string(), + justification: Some("stage files".to_string()), + path: None, + host: None, + target: Some("git add -A".to_string()), + command_prefix: Some(vec!["git".to_string(), "add".to_string()]), + requests_escalation: false, + }; + + let model_request = build_approval_review_request("model".to_string(), &request); + let RequestContent::Text { text } = &model_request.messages[0].content[0] else { + panic!("review request should contain text content"); + }; + assert!(text.contains("command_prefix: git add")); + assert!(text.contains("target: git add -A")); + } +} diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index 4d23ead..985225f 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -116,11 +116,12 @@ pub async fn run_server_process(args: ServerProcessArgs) -> Result<()> { tracing::info!(db_path = %db_path.display(), "opening database"); let db = Arc::new(Database::open(db_path)?); + let registry = Arc::new(registry); let runtime = ServerRuntime::new( resolver.user_config_dir(), ServerRuntimeDependencies::new( provider.provider, - Arc::new(registry), + Arc::clone(®istry), provider.default_model, model_catalog, skill_workspace_root, @@ -143,6 +144,8 @@ pub async fn run_server_process(args: ServerProcessArgs) -> Result<()> { tracing::info!("server shutdown requested"); } } + tracing::info!("terminating unified exec processes"); + registry.terminate_unified_exec_processes().await; tracing::info!("completing deferred items for active turns"); runtime.shutdown().await; Ok(()) diff --git a/crates/server/src/event.rs b/crates/server/src/event.rs index 8644f07..3691e50 100644 --- a/crates/server/src/event.rs +++ b/crates/server/src/event.rs @@ -1,7 +1,8 @@ pub use devo_protocol::{ - ApprovalRequestPayload, EventContext, ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, - ItemEventPayload, ItemKind, PendingServerRequestContext, RequestUserInputPayload, ServerEvent, - ServerRequestKind, ServerRequestResolvedPayload, SessionCompactionFailedPayload, - SessionEventPayload, SessionStatusChangedPayload, ToolCallPayload, ToolResultPayload, - TurnEventPayload, TurnUsageUpdatedPayload, + ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext, + ItemDeltaKind, ItemDeltaPayload, ItemEnvelope, ItemEventPayload, ItemKind, + PendingServerRequestContext, RequestUserInputPayload, ServerEvent, ServerRequestKind, + ServerRequestResolvedPayload, SessionCompactionFailedPayload, SessionEventPayload, + SessionStatusChangedPayload, ToolCallPayload, ToolResultPayload, TurnEventPayload, + TurnUsageUpdatedPayload, }; diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index ae431f9..f55ed0f 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -5,6 +7,7 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use tokio::sync::Mutex; +use tokio::sync::oneshot; use devo_core::AgentsMdConfig; use devo_core::Model; @@ -21,6 +24,7 @@ use devo_core::TurnConfig; use devo_core::TurnId; use devo_core::default_base_instructions; use devo_core::normalize_canonical_path; +use devo_protocol::ApprovalDecisionValue; use devo_protocol::PendingInputItem; use devo_provider::ModelProviderSDK; use devo_tools::ToolRegistry; @@ -39,6 +43,23 @@ pub(crate) struct PersistedTurnItem { pub(crate) turn_item: devo_core::TurnItem, } +pub(crate) struct PendingApproval { + pub(crate) turn_id: TurnId, + pub(crate) tool_name: String, + pub(crate) path: Option, + pub(crate) host: Option, + pub(crate) command_prefix: Option>, + pub(crate) tx: oneshot::Sender, +} + +#[derive(Default)] +pub(crate) struct ApprovalGrantCache { + pub(crate) tools: HashSet, + pub(crate) hosts: HashSet, + pub(crate) path_prefixes: HashSet, + pub(crate) command_prefixes: HashSet>, +} + /// Shared server-owned runtime dependencies used by live turn execution. pub struct ServerRuntimeDependencies { /// Provider used for all model requests. @@ -93,8 +114,14 @@ impl ServerRuntimeDependencies { /// Creates an initial core session state for a newly created server session. pub(crate) fn new_session_state(&self, session_id: SessionId, cwd: PathBuf) -> SessionState { + let permission_profile = devo_safety::RuntimePermissionProfile::from_preset( + devo_safety::PermissionPreset::Default, + cwd.clone(), + ); let mut state = SessionState::new( SessionConfig { + permission_mode: permission_profile.permission_mode(), + permission_profile, agents_md: self.agents_md.clone(), ..SessionConfig::default() }, @@ -253,6 +280,12 @@ pub(crate) struct RuntimeSession { pub(crate) next_item_seq: u64, /// First user input captured from the session's first turn, used for title generation. pub(crate) first_user_input: Option, + /// Active approval requests waiting for client decisions. + pub(crate) pending_approvals: HashMap, + /// Session-scoped approvals granted through approval/respond. + pub(crate) session_approval_cache: ApprovalGrantCache, + /// Turn-scoped approvals granted through approval/respond. + pub(crate) turn_approval_cache: ApprovalGrantCache, } impl RuntimeSession { diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 04e9648..45d1d56 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,4 +1,5 @@ mod approval; +mod approval_reviewer; mod bootstrap; mod client; mod connection; diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index bd220ab..4303c4a 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -15,6 +15,7 @@ use chrono::SecondsFormat; use chrono::Utc; use tokio::sync::Mutex; +use devo_core::CommandExecutionItem; use devo_core::CompactionSnapshotLine; use devo_core::ContentBlock; use devo_core::ItemId; @@ -577,6 +578,9 @@ impl ReplayState { deferred_reasoning: None, next_item_seq: self.next_item_seq.max(1), first_user_input: None, + pending_approvals: std::collections::HashMap::new(), + session_approval_cache: crate::execution::ApprovalGrantCache::default(), + turn_approval_cache: crate::execution::ApprovalGrantCache::default(), }) } @@ -688,6 +692,7 @@ fn prompt_visible_turn_item(item: &TurnItem) -> bool { | TurnItem::Reasoning(_) | TurnItem::ToolCall(_) | TurnItem::ToolResult(_) + | TurnItem::CommandExecution(_) | TurnItem::Plan(_) | TurnItem::WebSearch(_) | TurnItem::ImageGeneration(_) @@ -725,6 +730,24 @@ pub(crate) fn apply_turn_item( output, is_error, }), + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + command, + input, + output, + is_error, + }) => { + tool_names_by_id.insert(tool_call_id.clone(), tool_name.clone()); + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + command, + input, + output, + is_error, + }) + } other => other, }; @@ -802,6 +825,46 @@ pub(crate) fn apply_turn_item( } } } + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + input, + output, + is_error, + .. + }) => { + match messages.last_mut() { + Some(message) if message.role == Role::Assistant => { + message.content.push(ContentBlock::ToolUse { + id: tool_call_id.clone(), + name: tool_name, + input, + }); + } + _ => { + messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: tool_call_id.clone(), + name: tool_name, + input, + }], + }); + } + } + let content = match output { + serde_json::Value::String(text) => text, + other => other.to_string(), + }; + messages.push(Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: tool_call_id, + content, + is_error, + }], + }); + } TurnItem::Plan(TextItem { text }) | TurnItem::WebSearch(TextItem { text }) | TurnItem::ImageGeneration(TextItem { text }) @@ -856,6 +919,24 @@ fn apply_prompt_turn_item( output, is_error, }), + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + command, + input, + output, + is_error, + }) => { + tool_names_by_id.insert(tool_call_id.clone(), tool_name.clone()); + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + command, + input, + output, + is_error, + }) + } other => other, }; @@ -930,6 +1011,46 @@ fn apply_prompt_turn_item( } } } + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + input, + output, + is_error, + .. + }) => { + match messages.last_mut() { + Some(message) if message.role == Role::Assistant => { + message.content.push(ContentBlock::ToolUse { + id: tool_call_id.clone(), + name: tool_name, + input, + }); + } + _ => { + messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: tool_call_id.clone(), + name: tool_name, + input, + }], + }); + } + } + let content = match output { + serde_json::Value::String(text) => text, + other => other.to_string(), + }; + messages.push(Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: tool_call_id, + content, + is_error, + }], + }); + } TurnItem::Reasoning(TextItem { text }) => match messages.last_mut() { Some(message) if message.role == Role::Assistant => { message.content.push(ContentBlock::Reasoning { text }); diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index f40587a..b18cd0b 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -1,6 +1,6 @@ use devo_core::{ - ContentBlock, Message, SessionRecord, TextItem, ToolCallItem, ToolResultItem, TurnItem, - TurnRecord, + CommandExecutionItem, ContentBlock, Message, SessionRecord, TextItem, ToolCallItem, + ToolResultItem, TurnItem, TurnRecord, }; use crate::session::{ @@ -152,6 +152,25 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option other.to_string(), }, )), + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + command, + output, + is_error, + .. + }) => Some(SessionHistoryItem::new( + Some(tool_call_id.clone()), + if *is_error { + SessionHistoryItemKind::Error + } else { + SessionHistoryItemKind::CommandExecution + }, + command.clone(), + match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }, + )), TurnItem::ToolProgress(_) | TurnItem::ApprovalRequest(_) | TurnItem::ApprovalDecision(_) => None, diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index b7282c1..d6515b5 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -10,7 +10,11 @@ use chrono::Utc; use futures::FutureExt; use tokio::sync::Mutex; use tokio::sync::mpsc; +use tokio::sync::oneshot; +use devo_core::ApprovalDecisionItem; +use devo_core::ApprovalRequestItem; +use devo_core::CommandExecutionItem; use devo_core::ItemId; use devo_core::Message; use devo_core::QueryEvent; @@ -35,9 +39,16 @@ use devo_core::history::compaction::compact_history; use devo_core::history::summarizer::DefaultHistorySummarizer; use devo_core::message_to_response_items; use devo_core::query; +use devo_safety::PermissionMode; use devo_tools::ToolRuntime; +use devo_tools::{PermissionChecker, ToolPermissionRequest, ToolRuntimeContext}; +use crate::ApprovalDecisionValue; +use crate::ApprovalRequestPayload; +use crate::ApprovalRespondParams; +use crate::ApprovalScopeValue; use crate::ClientTransportKind; +use crate::CommandExecutionPayload; use crate::ConnectionState; use crate::ErrorResponse; use crate::EventContext; @@ -65,6 +76,8 @@ use crate::SessionListParams; use crate::SessionListResult; use crate::SessionMetadataUpdateParams; use crate::SessionMetadataUpdateResult; +use crate::SessionPermissionsUpdateParams; +use crate::SessionPermissionsUpdateResult; use crate::SessionResumeParams; use crate::SessionResumeResult; use crate::SessionRollbackParams; @@ -87,8 +100,12 @@ use crate::TurnStartResult; use crate::TurnSteerParams; use crate::TurnSteerResult; use crate::TurnUsageUpdatedPayload; +use crate::approval_reviewer::ReviewerDecision; +use crate::approval_reviewer::build_approval_review_request; +use crate::approval_reviewer::parse_reviewer_decision; use crate::db::QueueType; use crate::db::SessionStats; +use crate::execution::PendingApproval; use crate::execution::RuntimeSession; use crate::execution::ServerRuntimeDependencies; use crate::persistence::RolloutStore; @@ -99,6 +116,17 @@ use crate::titles::build_title_generation_request; use crate::titles::derive_provisional_title; use crate::titles::normalize_generated_title; +enum PolicyAuthorization { + Allow, + Ask, +} + +enum AutoReviewOutcome { + Approve, + Deny(String), + AskUser, +} + mod handlers; mod model_api; mod skills; @@ -454,6 +482,9 @@ impl ServerRuntime { "session/metadata/update" => { Some(self.handle_session_metadata_update(id?, params).await) } + "session/permissions/update" => { + Some(self.handle_session_permissions_update(id?, params).await) + } "session/title/update" => Some(self.handle_session_title_update(id?, params).await), "session/resume" => Some(self.handle_session_resume(connection_id, id?, params).await), "session/fork" => Some(self.handle_session_fork(connection_id, id?, params).await), @@ -469,11 +500,7 @@ impl ServerRuntime { "turn/start" => Some(self.handle_turn_start(id?, params).await), "turn/interrupt" => Some(self.handle_turn_interrupt(id?, params).await), "turn/steer" => Some(self.handle_turn_steer(connection_id, id?, params).await), - "approval/respond" => Some(self.error_response( - id?, - ProtocolErrorCode::ApprovalNotFound, - "no pending approval request exists for this runtime", - )), + "approval/respond" => Some(self.handle_approval_respond(id?, params).await), "events/subscribe" => Some( self.handle_events_subscribe(connection_id, id?, params) .await, @@ -491,6 +518,399 @@ impl ServerRuntime { } } + async fn handle_approval_respond( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: ApprovalRespondParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid approval/respond params: {error}"), + ); + } + }; + + let Some(session_arc) = self.sessions.lock().await.get(¶ms.session_id).cloned() else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + + let approval_id = params.approval_id.to_string(); + let pending = { + let mut session = session_arc.lock().await; + let Some(pending) = session.pending_approvals.remove(&approval_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::ApprovalNotFound, + "no pending approval request exists for this runtime", + ); + }; + if pending.turn_id != params.turn_id { + session.pending_approvals.insert(approval_id, pending); + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "approval request belongs to a different turn", + ); + } + + if matches!(params.decision, ApprovalDecisionValue::Approve) { + apply_approval_scope(&mut session, ¶ms.scope, &pending); + } + pending + }; + + self.emit_turn_item( + params.session_id, + params.turn_id, + ItemKind::ApprovalDecision, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: approval_id.clone(), + decision: approval_decision_label(¶ms.decision).to_string(), + scope: approval_scope_label(¶ms.scope).to_string(), + }), + serde_json::to_value(devo_protocol::ApprovalDecisionPayload { + approval_id: approval_id.clone().into(), + decision: approval_decision_label(¶ms.decision).to_string(), + scope: approval_scope_label(¶ms.scope).to_string(), + }) + .expect("serialize approval decision payload"), + ) + .await; + + let _ = pending.tx.send(params.decision); + serde_json::to_value(SuccessResponse { + id: request_id, + result: serde_json::json!({ "approval_id": approval_id }), + }) + .expect("serialize approval response") + } + + fn build_permission_checker( + self: &Arc, + session_id: SessionId, + turn_id: TurnId, + permission_mode: PermissionMode, + permission_profile: devo_safety::RuntimePermissionProfile, + ) -> PermissionChecker { + let runtime = Arc::clone(self); + PermissionChecker::new(move |request| { + let runtime = Arc::clone(&runtime); + let permission_profile = permission_profile.clone(); + Box::pin(async move { + runtime + .authorize_tool_request( + session_id, + turn_id, + permission_mode, + permission_profile, + request, + ) + .await + }) + }) + } + + async fn authorize_tool_request( + &self, + session_id: SessionId, + turn_id: TurnId, + permission_mode: PermissionMode, + permission_profile: devo_safety::RuntimePermissionProfile, + request: ToolPermissionRequest, + ) -> Result<(), String> { + if let Some(result) = permission_mode_authorization(permission_mode) { + return result; + } + if self.approval_cache_allows(session_id, &request).await { + return Ok(()); + } + match self.policy_decision(&permission_profile, &request) { + PolicyAuthorization::Allow => Ok(()), + PolicyAuthorization::Ask => { + if matches!( + permission_profile.reviewer, + devo_safety::ApprovalsReviewer::AutoReview + ) { + match self + .auto_review_tool_request(session_id, turn_id, &request) + .await + { + AutoReviewOutcome::Approve => return Ok(()), + AutoReviewOutcome::Deny(reason) => { + return Err(format!("rejected by auto-reviewer: {reason}")); + } + AutoReviewOutcome::AskUser => {} + } + } + self.request_tool_approval(session_id, turn_id, request) + .await + } + } + } + + async fn auto_review_tool_request( + &self, + session_id: SessionId, + turn_id: TurnId, + request: &ToolPermissionRequest, + ) -> AutoReviewOutcome { + let model = { + let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() else { + return AutoReviewOutcome::AskUser; + }; + let session = session_arc.lock().await; + session + .summary + .model + .clone() + .unwrap_or_else(|| self.deps.default_model.clone()) + }; + let response = match self + .deps + .provider + .completion(build_approval_review_request(model, request)) + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!( + session_id = %session_id, + tool = %request.tool_name, + error = %error, + "auto-review approval request failed" + ); + return AutoReviewOutcome::AskUser; + } + }; + match parse_reviewer_decision(&response.content) { + Some(ReviewerDecision::Approve { rationale }) => { + tracing::info!( + session_id = %session_id, + tool = %request.tool_name, + rationale = %rationale, + "auto-review approved tool request" + ); + self.emit_auto_review_decision( + session_id, + turn_id, + request, + "approve", + rationale.as_str(), + ) + .await; + AutoReviewOutcome::Approve + } + Some(ReviewerDecision::Deny { rationale }) => { + tracing::warn!( + session_id = %session_id, + tool = %request.tool_name, + rationale = %rationale, + "auto-review denied tool request" + ); + self.emit_auto_review_decision( + session_id, + turn_id, + request, + "deny", + rationale.as_str(), + ) + .await; + AutoReviewOutcome::Deny(rationale) + } + Some(ReviewerDecision::Uncertain { rationale }) => { + tracing::info!( + session_id = %session_id, + tool = %request.tool_name, + rationale = %rationale, + "auto-review deferred tool request to user" + ); + AutoReviewOutcome::AskUser + } + None => { + tracing::warn!( + session_id = %session_id, + tool = %request.tool_name, + "auto-review returned an invalid decision" + ); + AutoReviewOutcome::AskUser + } + } + } + + async fn emit_auto_review_decision( + &self, + session_id: SessionId, + turn_id: TurnId, + request: &ToolPermissionRequest, + decision: &str, + rationale: &str, + ) { + let approval_id = format!("auto-review-{}", request.tool_call_id); + self.emit_turn_item( + session_id, + turn_id, + ItemKind::ApprovalDecision, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: approval_id.clone(), + decision: format!("auto_review_{decision}"), + scope: "auto_review".to_string(), + }), + serde_json::json!({ + "approval_id": approval_id, + "decision": format!("auto_review_{decision}"), + "scope": "auto_review", + "rationale": rationale, + "tool_name": request.tool_name, + "resource": format!("{:?}", request.resource), + "target": request.target, + }), + ) + .await; + } + + fn policy_decision( + &self, + profile: &devo_safety::RuntimePermissionProfile, + request: &ToolPermissionRequest, + ) -> PolicyAuthorization { + if profile.auto_approve { + return PolicyAuthorization::Allow; + } + if request_forces_approval(request) { + return PolicyAuthorization::Ask; + } + match request.resource { + devo_safety::ResourceKind::Network => { + if profile.allow_network { + PolicyAuthorization::Allow + } else { + PolicyAuthorization::Ask + } + } + devo_safety::ResourceKind::ShellExec => { + if profile.allow_shell_commands { + PolicyAuthorization::Allow + } else { + PolicyAuthorization::Ask + } + } + devo_safety::ResourceKind::FileWrite => { + let Some(path) = request.path.as_ref() else { + return PolicyAuthorization::Ask; + }; + if profile + .writable_roots + .iter() + .any(|root| path.starts_with(root)) + { + PolicyAuthorization::Allow + } else { + PolicyAuthorization::Ask + } + } + devo_safety::ResourceKind::FileRead | devo_safety::ResourceKind::Custom(_) => { + PolicyAuthorization::Allow + } + } + } + + async fn approval_cache_allows( + &self, + session_id: SessionId, + request: &ToolPermissionRequest, + ) -> bool { + let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() else { + return false; + }; + let session = session_arc.lock().await; + cache_allows(&session.session_approval_cache, request) + || cache_allows(&session.turn_approval_cache, request) + } + + async fn request_tool_approval( + &self, + session_id: SessionId, + turn_id: TurnId, + request: ToolPermissionRequest, + ) -> Result<(), String> { + let approval_id = format!("approval-{}", request.tool_call_id); + let (tx, rx) = oneshot::channel(); + let available_scopes = approval_scopes_for_request(&request); + + let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() else { + return Err("session does not exist".to_string()); + }; + { + let mut session = session_arc.lock().await; + session.pending_approvals.insert( + approval_id.clone(), + PendingApproval { + turn_id, + tool_name: request.tool_name.clone(), + path: request.path.clone(), + host: request.host.clone(), + command_prefix: request.command_prefix.clone(), + tx, + }, + ); + } + + let request_context = crate::PendingServerRequestContext { + request_id: approval_id.clone().into(), + request_kind: crate::ServerRequestKind::ItemPermissionsRequestApproval, + session_id, + turn_id: Some(turn_id), + item_id: None, + }; + let justification = request + .justification + .clone() + .unwrap_or_else(|| "Tool execution requires approval.".to_string()); + let payload = ApprovalRequestPayload { + request: request_context, + approval_id: approval_id.clone().into(), + action_summary: request.action_summary.clone(), + justification: justification.clone(), + resource: Some(format!("{:?}", request.resource)), + available_scopes: available_scopes.clone(), + path: request.path.as_ref().map(|path| path.display().to_string()), + host: request.host.clone(), + target: request.target.clone(), + }; + self.emit_turn_item( + session_id, + turn_id, + ItemKind::ApprovalRequest, + TurnItem::ApprovalRequest(ApprovalRequestItem { + approval_id: approval_id.clone(), + action_summary: request.action_summary, + justification, + resource: Some(format!("{:?}", request.resource)), + available_scopes, + path: request.path.map(|path| path.display().to_string()), + host: request.host, + target: request.target, + }), + serde_json::to_value(payload).expect("serialize approval request payload"), + ) + .await; + + match rx.await { + Ok(ApprovalDecisionValue::Approve) => Ok(()), + Ok(ApprovalDecisionValue::Deny) => Err("rejected by user".to_string()), + Ok(ApprovalDecisionValue::Cancel) => Err("cancelled by user".to_string()), + Err(_) => Err("approval channel closed".to_string()), + } + } + async fn maybe_assign_provisional_title(&self, session_id: SessionId, first_user_input: &str) { let Some(candidate) = derive_provisional_title(first_user_input) else { return; @@ -943,3 +1363,245 @@ fn render_input_items(input: &[crate::InputItem]) -> Option { .collect::>(); (!parts.is_empty()).then(|| parts.join("\n")) } + +fn approval_decision_label(decision: &ApprovalDecisionValue) -> &'static str { + match decision { + ApprovalDecisionValue::Approve => "approve", + ApprovalDecisionValue::Deny => "deny", + ApprovalDecisionValue::Cancel => "cancel", + } +} + +fn approval_scope_label(scope: &ApprovalScopeValue) -> &'static str { + match scope { + ApprovalScopeValue::Once => "once", + ApprovalScopeValue::Turn => "turn", + ApprovalScopeValue::Session => "session", + ApprovalScopeValue::PathPrefix => "path_prefix", + ApprovalScopeValue::Host => "host", + ApprovalScopeValue::Tool => "tool", + ApprovalScopeValue::CommandPrefix => "command_prefix", + } +} + +fn approval_scopes_for_request(request: &ToolPermissionRequest) -> Vec { + let mut scopes = vec![ + "once".to_string(), + "turn".to_string(), + "session".to_string(), + ]; + if request.path.is_some() { + scopes.push("path_prefix".to_string()); + } + if request.host.is_some() { + scopes.push("host".to_string()); + } + if request.command_prefix.is_some() { + scopes.push("command_prefix".to_string()); + } + scopes.push("tool".to_string()); + scopes +} + +fn apply_approval_scope( + session: &mut RuntimeSession, + scope: &ApprovalScopeValue, + pending: &PendingApproval, +) { + match scope { + ApprovalScopeValue::Once => {} + ApprovalScopeValue::Turn => { + session + .turn_approval_cache + .tools + .insert(pending.tool_name.clone()); + } + ApprovalScopeValue::Session => { + session + .session_approval_cache + .tools + .insert(pending.tool_name.clone()); + } + ApprovalScopeValue::PathPrefix => { + if let Some(path) = pending.path.clone() { + session.turn_approval_cache.path_prefixes.insert(path); + } + } + ApprovalScopeValue::Host => { + if let Some(host) = pending.host.clone() { + session.turn_approval_cache.hosts.insert(host); + } + } + ApprovalScopeValue::Tool => { + session + .turn_approval_cache + .tools + .insert(pending.tool_name.clone()); + } + ApprovalScopeValue::CommandPrefix => { + if let Some(command_prefix) = pending.command_prefix.clone() { + session + .session_approval_cache + .command_prefixes + .insert(command_prefix); + } + } + } +} + +fn cache_allows( + cache: &crate::execution::ApprovalGrantCache, + request: &ToolPermissionRequest, +) -> bool { + if cache.tools.contains(&request.tool_name) { + return true; + } + if request + .host + .as_ref() + .is_some_and(|host| cache.hosts.contains(host)) + { + return true; + } + request.path.as_ref().is_some_and(|path| { + cache + .path_prefixes + .iter() + .any(|prefix| path.starts_with(prefix)) + }) || request.command_prefix.as_ref().is_some_and(|command| { + cache + .command_prefixes + .iter() + .any(|prefix| command.starts_with(prefix)) + }) +} + +fn request_forces_approval(request: &ToolPermissionRequest) -> bool { + request.requests_escalation +} + +fn permission_mode_authorization(mode: PermissionMode) -> Option> { + match mode { + PermissionMode::AutoApprove => Some(Ok(())), + PermissionMode::Deny => Some(Err("approval policy is deny".to_string())), + PermissionMode::Interactive => None, + } +} + +fn permission_mode_from_approval_policy(policy: &str) -> Option { + match policy { + "on-request" | "interactive" | "ask" => Some(PermissionMode::Interactive), + "never" | "auto" | "auto-approve" => Some(PermissionMode::AutoApprove), + "deny" => Some(PermissionMode::Deny), + _ => None, + } +} + +fn safety_profile_from_protocol( + preset: devo_protocol::PermissionPreset, + cwd: std::path::PathBuf, +) -> devo_safety::RuntimePermissionProfile { + let preset = match preset { + devo_protocol::PermissionPreset::ReadOnly => devo_safety::PermissionPreset::ReadOnly, + devo_protocol::PermissionPreset::Default => devo_safety::PermissionPreset::Default, + devo_protocol::PermissionPreset::AutoReview => devo_safety::PermissionPreset::AutoReview, + devo_protocol::PermissionPreset::FullAccess => devo_safety::PermissionPreset::FullAccess, + }; + devo_safety::RuntimePermissionProfile::from_preset(preset, cwd) +} + +fn protocol_reviewer_from_safety( + reviewer: devo_safety::ApprovalsReviewer, +) -> devo_protocol::ApprovalsReviewer { + match reviewer { + devo_safety::ApprovalsReviewer::User => devo_protocol::ApprovalsReviewer::User, + devo_safety::ApprovalsReviewer::AutoReview => devo_protocol::ApprovalsReviewer::AutoReview, + } +} + +#[cfg(test)] +mod permission_policy_tests { + use super::*; + + #[test] + fn approval_policy_strings_map_to_permission_modes() { + assert_eq!( + permission_mode_from_approval_policy("on-request"), + Some(PermissionMode::Interactive) + ); + assert_eq!( + permission_mode_from_approval_policy("never"), + Some(PermissionMode::AutoApprove) + ); + assert_eq!( + permission_mode_from_approval_policy("deny"), + Some(PermissionMode::Deny) + ); + assert_eq!(permission_mode_from_approval_policy("unknown"), None); + } + + #[test] + fn command_prefix_cache_allows_matching_command_prefix() { + let mut cache = crate::execution::ApprovalGrantCache::default(); + cache + .command_prefixes + .insert(vec!["git".to_string(), "add".to_string()]); + let mut request = test_permission_request("shell_command"); + request.command_prefix = Some(vec!["git".to_string(), "add".to_string()]); + assert!(cache_allows(&cache, &request)); + } + + #[test] + fn approval_scopes_include_command_prefix_for_shell_commands() { + let mut request = test_permission_request("shell_command"); + request.command_prefix = Some(vec!["git".to_string(), "add".to_string()]); + assert!( + approval_scopes_for_request(&request) + .iter() + .any(|scope| scope == "command_prefix") + ); + } + + #[test] + fn explicit_escalation_forces_approval() { + let mut request = test_permission_request("exec_command"); + request.requests_escalation = true; + + assert!(request_forces_approval(&request)); + } + + #[test] + fn permission_mode_overrides_authorization_policy() { + assert_eq!( + permission_mode_authorization(PermissionMode::AutoApprove), + Some(Ok(())) + ); + assert_eq!( + permission_mode_authorization(PermissionMode::Deny), + Some(Err("approval policy is deny".to_string())) + ); + assert_eq!( + permission_mode_authorization(PermissionMode::Interactive), + None + ); + } + + fn test_permission_request(tool_name: &str) -> ToolPermissionRequest { + ToolPermissionRequest { + tool_call_id: "call".into(), + tool_name: tool_name.into(), + input: serde_json::json!({}), + cwd: std::path::PathBuf::new(), + session_id: "session".into(), + turn_id: Some("turn".into()), + resource: devo_safety::ResourceKind::ShellExec, + action_summary: tool_name.into(), + justification: None, + path: None, + host: None, + target: None, + command_prefix: None, + requests_escalation: false, + } + } +} diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 2f53545..008f9cb 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -301,11 +301,11 @@ impl ServerRuntime { ) -> Vec { let normalized_persisted_items = persisted_turn_items .iter() - .filter_map(|item| { - let response_item = match &item.turn_item { + .flat_map(|item| { + let response_items = match &item.turn_item { TurnItem::UserMessage(TextItem { text }) | TurnItem::SteerInput(TextItem { text }) => { - ResponseItem::Message(Message::user(text.clone())) + vec![ResponseItem::Message(Message::user(text.clone()))] } TurnItem::AgentMessage(TextItem { text }) | TurnItem::Plan(TextItem { text }) @@ -313,39 +313,65 @@ impl ServerRuntime { | TurnItem::ImageGeneration(TextItem { text }) | TurnItem::ContextCompaction(TextItem { text }) | TurnItem::HookPrompt(TextItem { text }) => { - ResponseItem::Message(Message::assistant_text(text.clone())) + vec![ResponseItem::Message(Message::assistant_text(text.clone()))] } TurnItem::Reasoning(TextItem { text }) => { - ResponseItem::Reason { text: text.clone() } + vec![ResponseItem::Reason { text: text.clone() }] } TurnItem::ToolCall(ToolCallItem { tool_call_id, tool_name, input, - }) => ResponseItem::ToolCall { + }) => vec![ResponseItem::ToolCall { id: tool_call_id.clone(), name: tool_name.clone(), input: input.clone(), - }, + }], TurnItem::ToolResult(ToolResultItem { tool_call_id, output, is_error, .. - }) => ResponseItem::ToolCallOutput { + }) => vec![ResponseItem::ToolCallOutput { tool_use_id: tool_call_id.clone(), content: match output { serde_json::Value::String(text) => text.clone(), other => other.to_string(), }, is_error: *is_error, - }, + }], + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id, + tool_name, + input, + output, + is_error, + .. + }) => vec![ + ResponseItem::ToolCall { + id: tool_call_id.clone(), + name: tool_name.clone(), + input: input.clone(), + }, + ResponseItem::ToolCallOutput { + tool_use_id: tool_call_id.clone(), + content: match output { + serde_json::Value::String(text) => text.clone(), + other => other.to_string(), + }, + is_error: *is_error, + }, + ], TurnItem::ToolProgress(_) | TurnItem::ApprovalRequest(_) | TurnItem::ApprovalDecision(_) - | TurnItem::TurnSummary(_) => return None, + | TurnItem::TurnSummary(_) => Vec::new(), }; - (!response_item.is_reason()).then_some((item.item_id, response_item)) + response_items + .into_iter() + .filter(|response_item| !response_item.is_reason()) + .map(|response_item| (item.item_id, response_item)) + .collect::>() }) .collect::>(); let preserved = compacted_items.get(1..).unwrap_or(&[]); @@ -384,3 +410,50 @@ impl ServerRuntime { TurnItem::ContextCompaction(TextItem { text: summary_text }) } } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn preserved_item_ids_match_complete_command_execution_pair() { + let command_item_id = ItemId::new(); + let command_input = serde_json::json!({ "cmd": "printf ok" }); + let command_output = serde_json::Value::String("ok".to_string()); + let persisted_turn_items = vec![crate::execution::PersistedTurnItem { + turn_id: TurnId::new(), + item_id: command_item_id, + turn_item: TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: "call-1".to_string(), + tool_name: "exec_command".to_string(), + command: "printf ok".to_string(), + input: command_input.clone(), + output: command_output.clone(), + is_error: false, + }), + }]; + let compacted_items = vec![ + ResponseItem::Message(Message::assistant_text("summary")), + ResponseItem::ToolCall { + id: "call-1".to_string(), + name: "exec_command".to_string(), + input: command_input, + }, + ResponseItem::ToolCallOutput { + tool_use_id: "call-1".to_string(), + content: "ok".to_string(), + is_error: false, + }, + ]; + + assert_eq!( + ServerRuntime::preserved_item_ids_from_compacted( + &persisted_turn_items, + &compacted_items + ), + vec![command_item_id, command_item_id] + ); + } +} diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 53cdc78..51a40c0 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -130,6 +130,9 @@ impl ServerRuntime { deferred_reasoning: None, next_item_seq: 1, first_user_input: None, + pending_approvals: std::collections::HashMap::new(), + session_approval_cache: crate::execution::ApprovalGrantCache::default(), + turn_approval_cache: crate::execution::ApprovalGrantCache::default(), } .shared(), ); @@ -264,6 +267,53 @@ impl ServerRuntime { .expect("serialize session/metadata/update response") } + pub(crate) async fn handle_session_permissions_update( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionPermissionsUpdateParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/permissions/update params: {error}"), + ); + } + }; + let Some(session_arc) = self.sessions.lock().await.get(¶ms.session_id).cloned() else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + + let profile = { + let mut session = session_arc.lock().await; + let profile = safety_profile_from_protocol(params.preset, session.summary.cwd.clone()); + { + let mut core_session = session.core_session.lock().await; + core_session.config.permission_mode = profile.permission_mode(); + core_session.config.permission_profile = profile.clone(); + } + session.session_approval_cache = crate::execution::ApprovalGrantCache::default(); + session.turn_approval_cache = crate::execution::ApprovalGrantCache::default(); + profile + }; + + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionPermissionsUpdateResult { + session_id: params.session_id, + preset: params.preset, + reviewer: protocol_reviewer_from_safety(profile.reviewer), + }, + }) + .expect("serialize session/permissions/update response") + } + pub(crate) async fn handle_session_title_update( &self, request_id: serde_json::Value, @@ -722,6 +772,9 @@ impl ServerRuntime { next_item_seq: u64::try_from(source.persisted_turn_items.len().saturating_add(1)) .unwrap_or(u64::MAX), first_user_input: source.first_user_input.clone(), + pending_approvals: std::collections::HashMap::new(), + session_approval_cache: crate::execution::ApprovalGrantCache::default(), + turn_approval_cache: crate::execution::ApprovalGrantCache::default(), }) } } diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index bc71fb2..d37a313 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -133,6 +133,13 @@ impl ServerRuntime { session.summary.cwd = cwd.clone(); session.core_session.lock().await.cwd = cwd; } + if let Some(permission_mode) = params + .approval_policy + .as_deref() + .and_then(permission_mode_from_approval_policy) + { + session.core_session.lock().await.config.permission_mode = permission_mode; + } let requested_model = params.model.as_deref().or(session.summary.model.as_deref()); let requested_thinking = params .thinking diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 4c4d45c..ccd2e2e 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -5,6 +5,98 @@ use tokio::sync::mpsc; use super::*; +struct PendingToolCall { + item_id: ItemId, + item_seq: u64, + input: serde_json::Value, + is_command_execution: bool, + command: String, +} + +async fn complete_reasoning_item( + runtime: &Arc, + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: u64, + text: String, +) { + runtime + .complete_item( + session_id, + turn_id, + item_id, + item_seq, + ItemKind::Reasoning, + TurnItem::Reasoning(TextItem { text: text.clone() }), + serde_json::json!({ "title": "Reasoning", "text": text }), + ) + .await; +} + +async fn complete_assistant_item( + runtime: &Arc, + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + item_seq: u64, + text: String, +) { + runtime + .complete_item( + session_id, + turn_id, + item_id, + item_seq, + ItemKind::AgentMessage, + TurnItem::AgentMessage(TextItem { text: text.clone() }), + serde_json::json!({ "title": "Assistant", "text": text }), + ) + .await; +} + +fn is_unified_exec_tool(name: &str) -> bool { + matches!(name, "exec_command" | "write_stdin") +} + +fn command_display_from_input(tool_name: &str, input: &serde_json::Value) -> String { + match tool_name { + "exec_command" => input + .get("cmd") + .or_else(|| input.get("command")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + "write_stdin" => { + let session_id = input + .get("session_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()) + .unwrap_or_else(|| "?".to_string()); + let chars = input + .get("chars") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if chars.is_empty() { + format!("poll session {session_id}") + } else { + format!("write_stdin session {session_id}") + } + } + _ => String::new(), + } +} + +fn command_execution_item_id_for_progress( + pending_tool_calls: &HashMap, + tool_use_id: &str, +) -> Option { + pending_tool_calls + .get(tool_use_id) + .filter(|pending| pending.is_command_execution) + .map(|pending| pending.item_id) +} + impl ServerRuntime { /// Execute one turn end-to-end, including streaming query events, /// persisting turn state, and draining queued follow-up inputs. @@ -16,6 +108,10 @@ impl ServerRuntime { display_input: String, input: String, ) { + if let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() { + session_arc.lock().await.turn_approval_cache = + crate::execution::ApprovalGrantCache::default(); + } // Record the user's message immediately so the UI can show it even if // the model call or event stream takes a moment to start. self.emit_turn_item( @@ -47,8 +143,7 @@ impl ServerRuntime { let mut reasoning_item_seq = None; let mut reasoning_text = String::new(); let mut tool_names_by_id = HashMap::new(); - let mut pending_tool_calls: HashMap = - HashMap::new(); + let mut pending_tool_calls: HashMap = HashMap::new(); let mut latest_usage: Option = None; let mut usage_base: Option<(usize, usize, usize)> = None; while let Some(event) = event_rx.recv().await { @@ -139,64 +234,97 @@ impl ServerRuntime { Some((item_id, item_seq, reasoning_text.clone())); } } - QueryEvent::ToolUseStart { id, name, input } => { - tool_names_by_id.insert(id.clone(), name.clone()); + QueryEvent::ReasoningCompleted => { if let (Some(item_id), Some(item_seq)) = - (assistant_item_id.take(), assistant_item_seq.take()) + (reasoning_item_id.take(), reasoning_item_seq.take()) { - runtime - .complete_item( - session_id, - turn_for_events.turn_id, - item_id, - item_seq, - ItemKind::AgentMessage, - TurnItem::AgentMessage(TextItem { - text: assistant_text.clone(), - }), - serde_json::json!({ - "title": "Assistant", - "text": assistant_text, - }), - ) - .await; - assistant_text.clear(); + if let Ok(mut session) = event_session_arc.try_lock() { + session.deferred_reasoning.take(); + } + complete_reasoning_item( + &runtime, + session_id, + turn_for_events.turn_id, + item_id, + item_seq, + reasoning_text.clone(), + ) + .await; + reasoning_text.clear(); } + } + QueryEvent::ToolUseStart { id, name, input } => { + tool_names_by_id.insert(id.clone(), name.clone()); if let (Some(item_id), Some(item_seq)) = (reasoning_item_id.take(), reasoning_item_seq.take()) { - runtime - .complete_item( - session_id, - turn_for_events.turn_id, - item_id, - item_seq, - ItemKind::Reasoning, - TurnItem::Reasoning(TextItem { - text: reasoning_text.clone(), - }), - serde_json::json!({ - "title": "Reasoning", - "text": reasoning_text, - }), - ) - .await; + complete_reasoning_item( + &runtime, + session_id, + turn_for_events.turn_id, + item_id, + item_seq, + reasoning_text.clone(), + ) + .await; reasoning_text.clear(); } + if let (Some(item_id), Some(item_seq)) = + (assistant_item_id.take(), assistant_item_seq.take()) + { + complete_assistant_item( + &runtime, + session_id, + turn_for_events.turn_id, + item_id, + item_seq, + assistant_text.clone(), + ) + .await; + assistant_text.clear(); + } + let is_command_execution = is_unified_exec_tool(&name); + let command = command_display_from_input(&name, &input); + let item_kind = if is_command_execution { + ItemKind::CommandExecution + } else { + ItemKind::ToolCall + }; + let started_payload = if is_command_execution { + serde_json::to_value(CommandExecutionPayload { + tool_call_id: id.clone(), + tool_name: name.clone(), + command: command.clone(), + output: None, + is_error: false, + }) + .expect("serialize command execution payload") + } else { + serde_json::to_value(ToolCallPayload { + tool_call_id: id.clone(), + tool_name: name.clone(), + parameters: input.clone(), + }) + .expect("serialize tool call payload") + }; let (item_id, item_seq) = runtime .start_item( session_id, turn_for_events.turn_id, - ItemKind::ToolCall, - serde_json::to_value(ToolCallPayload { - tool_call_id: id.clone(), - tool_name: name.clone(), - parameters: input.clone(), - }) - .expect("serialize tool call payload"), + item_kind, + started_payload, ) .await; - pending_tool_calls.insert(id, (item_id, item_seq, input)); + pending_tool_calls.insert( + id, + PendingToolCall { + item_id, + item_seq, + input, + is_command_execution, + command, + }, + ); } QueryEvent::ToolResult { tool_use_id, @@ -207,26 +335,56 @@ impl ServerRuntime { let tool_name = tool_names_by_id.get(&tool_use_id).cloned(); // First complete the pending ToolCall item so its item/completed // arrives before the ToolResult item/completed. - if let Some((item_id, item_seq, tool_input)) = - pending_tool_calls.remove(&tool_use_id) - { + if let Some(pending) = pending_tool_calls.remove(&tool_use_id) { + if pending.is_command_execution { + let tool_name = tool_name.clone().unwrap_or_default(); + let output = serde_json::Value::String(content.clone()); + let completed_payload = + serde_json::to_value(CommandExecutionPayload { + tool_call_id: tool_use_id.clone(), + tool_name: tool_name.clone(), + command: pending.command.clone(), + output: Some(output.clone()), + is_error, + }) + .expect("serialize command execution payload"); + runtime + .complete_item( + session_id, + turn_for_events.turn_id, + pending.item_id, + pending.item_seq, + ItemKind::CommandExecution, + TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: tool_use_id.clone(), + tool_name, + command: pending.command, + input: pending.input, + output, + is_error, + }), + completed_payload, + ) + .await; + continue; + } let completed_payload = serde_json::to_value(ToolCallPayload { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone().unwrap_or_default(), - parameters: tool_input.clone(), + parameters: pending.input.clone(), }) .expect("serialize tool call payload"); runtime .complete_item( session_id, turn_for_events.turn_id, - item_id, - item_seq, + pending.item_id, + pending.item_seq, ItemKind::ToolCall, TurnItem::ToolCall(ToolCallItem { tool_call_id: tool_use_id.clone(), tool_name: tool_name.clone().unwrap_or_default(), - input: tool_input, + input: pending.input, }), completed_payload, ) @@ -258,6 +416,10 @@ impl ServerRuntime { tool_use_id, content, } => { + let item_id = command_execution_item_id_for_progress( + &pending_tool_calls, + &tool_use_id, + ); let _ = runtime .broadcast_event(ServerEvent::ItemDelta { delta_kind: ItemDeltaKind::CommandExecutionOutputDelta, @@ -265,7 +427,7 @@ impl ServerRuntime { context: EventContext { session_id, turn_id: Some(turn_for_events.turn_id), - item_id: None, + item_id, seq: 0, }, delta: serde_json::json!({ @@ -395,35 +557,31 @@ impl ServerRuntime { // and completes them; if they're already None we must skip to avoid persisting duplicates. if let Some((item_id, item_seq, text)) = { let mut session = event_session_arc.lock().await; - session.deferred_assistant.take() + session.deferred_reasoning.take() } { - runtime - .complete_item( - session_id, - turn_for_events.turn_id, - item_id, - item_seq, - ItemKind::AgentMessage, - TurnItem::AgentMessage(TextItem { text: text.clone() }), - serde_json::json!({ "title": "Assistant", "text": text }), - ) - .await; + complete_reasoning_item( + &runtime, + session_id, + turn_for_events.turn_id, + item_id, + item_seq, + text, + ) + .await; } if let Some((item_id, item_seq, text)) = { let mut session = event_session_arc.lock().await; - session.deferred_reasoning.take() + session.deferred_assistant.take() } { - runtime - .complete_item( - session_id, - turn_for_events.turn_id, - item_id, - item_seq, - ItemKind::Reasoning, - TurnItem::Reasoning(TextItem { text: text.clone() }), - serde_json::json!({ "title": "Reasoning", "text": text }), - ) - .await; + complete_assistant_item( + &runtime, + session_id, + turn_for_events.turn_id, + item_id, + item_seq, + text, + ) + .await; } latest_usage }); @@ -450,7 +608,22 @@ impl ServerRuntime { let _ = event_callback_tx.send(event); }); let registry = Arc::clone(&self.deps.registry); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let permission_mode = core_session.config.permission_mode; + let permission_profile = core_session.config.permission_profile.clone(); + let runtime = ToolRuntime::new_with_context( + Arc::clone(®istry), + self.build_permission_checker( + session_id, + turn_for_events.turn_id, + permission_mode, + permission_profile, + ), + ToolRuntimeContext { + session_id: session_id.to_string(), + turn_id: Some(turn_for_events.turn_id.to_string()), + cwd: core_session.cwd.clone(), + }, + ); let result = query( &mut core_session, &turn_config, @@ -845,3 +1018,49 @@ impl ServerRuntime { .await; } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn command_progress_uses_command_execution_item_id() { + let command_item_id = ItemId::new(); + let tool_item_id = ItemId::new(); + let mut pending_tool_calls = HashMap::new(); + pending_tool_calls.insert( + "exec".to_string(), + PendingToolCall { + item_id: command_item_id, + item_seq: 1, + input: serde_json::json!({}), + is_command_execution: true, + command: "cargo test".to_string(), + }, + ); + pending_tool_calls.insert( + "read".to_string(), + PendingToolCall { + item_id: tool_item_id, + item_seq: 2, + input: serde_json::json!({}), + is_command_execution: false, + command: String::new(), + }, + ); + + assert_eq!( + command_execution_item_id_for_progress(&pending_tool_calls, "exec"), + Some(command_item_id) + ); + assert_eq!( + command_execution_item_id_for_progress(&pending_tool_calls, "read"), + None + ); + assert_eq!( + command_execution_item_id_for_progress(&pending_tool_calls, "missing"), + None + ); + } +} diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 779e3b2..983de56 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -1,7 +1,8 @@ pub use devo_protocol::{ SessionCompactParams, SessionCompactResult, SessionForkParams, SessionForkResult, SessionHistoryItem, SessionHistoryItemKind, SessionListParams, SessionListResult, - SessionMetadata, SessionMetadataUpdateParams, SessionMetadataUpdateResult, SessionResumeParams, + SessionMetadata, SessionMetadataUpdateParams, SessionMetadataUpdateResult, + SessionPermissionsUpdateParams, SessionPermissionsUpdateResult, SessionResumeParams, SessionResumeResult, SessionRollbackParams, SessionRollbackResult, SessionRuntimeStatus, SessionStartParams, SessionStartResult, SessionTitleUpdateParams, SessionTitleUpdateResult, }; diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index c372500..b155798 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -141,6 +141,11 @@ fn server_request_payload_roundtrip() { approval_id: "approval-1".into(), action_summary: "run shell command".into(), justification: "writes files".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["once".into(), "turn".into(), "session".into()], + path: None, + host: None, + target: Some("echo hi".into()), }; let json = serde_json::to_string(&payload).expect("serialize"); diff --git a/crates/server/tests/skills_integration.rs b/crates/server/tests/skills_integration.rs index 433c294..5bdaca9 100644 --- a/crates/server/tests/skills_integration.rs +++ b/crates/server/tests/skills_integration.rs @@ -244,6 +244,28 @@ async fn wait_for_turn_completed( Ok(()) } +async fn wait_for_approval_request( + notifications_rx: &mut mpsc::UnboundedReceiver, +) -> Result<()> { + timeout(Duration::from_secs(5), async { + while let Some(value) = notifications_rx.recv().await { + if value.get("method") == Some(&serde_json::json!("item/started")) + && value + .get("params") + .and_then(|params| params.get("item")) + .and_then(|item| item.get("item_kind")) + == Some(&serde_json::json!("approval_request")) + { + return Ok(()); + } + } + anyhow::bail!("notification channel closed before approval request") + }) + .await + .context("timed out waiting for approval request")??; + Ok(()) +} + fn user_request_text(request: &ModelRequest) -> Result { let text = all_user_request_texts(request).join("\n"); (!text.is_empty()) @@ -266,6 +288,80 @@ fn all_user_request_texts(request: &ModelRequest) -> Vec { .collect() } +fn auto_review_registry(calls: Arc) -> Arc { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(RecordingMutatingTool { calls })); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: "Mutates test state.".into(), + input_schema: JsonSchema::object(std::collections::BTreeMap::new(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![devo_tools::ToolCapabilityTag::WriteFiles], + supports_parallel: false, + }); + Arc::new(builder.build()) +} + +async fn update_permissions_to_auto_review( + runtime: &Arc, + connection_id: u64, + session_id: devo_core::SessionId, +) -> Result<()> { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 3, + "method": "session/permissions/update", + "params": { + "session_id": session_id, + "preset": "auto-review" + } + }), + ) + .await + .context("session/permissions/update response")?; + let result: SuccessResponse = + serde_json::from_value(response)?; + assert_eq!( + result.result.preset, + devo_protocol::PermissionPreset::AutoReview + ); + Ok(()) +} + +async fn start_auto_review_turn( + runtime: &Arc, + connection_id: u64, + session_id: devo_core::SessionId, +) -> Result<()> { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 4, + "method": "turn/start", + "params": { + "session_id": session_id, + "input": [ + { "type": "text", "text": "Use the mutating tool." } + ], + "model": null, + "thinking": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .context("turn/start auto-review response")?; + let result: SuccessResponse = serde_json::from_value(response)?; + assert_eq!(result.result.status, devo_core::TurnStatus::Running); + Ok(()) +} + struct BlockingReadOnlyTool { started: Arc, release: Arc, @@ -288,6 +384,107 @@ impl ToolHandler for BlockingReadOnlyTool { } } +struct RecordingMutatingTool { + calls: Arc, +} + +#[async_trait] +impl ToolHandler for RecordingMutatingTool { + fn tool_kind(&self) -> devo_tools::ToolHandlerKind { + devo_tools::ToolHandlerKind::Write + } + + async fn handle( + &self, + _invocation: ToolInvocation, + _progress: Option, + ) -> Result, devo_tools::ToolExecutionError> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Box::new(FunctionToolOutput::success("mutated"))) + } +} + +struct AutoReviewProvider { + decision: &'static str, + reviewer_calls: Arc, + stream_calls: Arc, +} + +#[async_trait] +impl ModelProviderSDK for AutoReviewProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + self.reviewer_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(ModelResponse { + id: "review-1".into(), + content: vec![ResponseContent::Text(format!( + r#"{{"decision":"{}","rationale":"test decision"}}"#, + self.decision + ))], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }) + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let stream_call = self + .stream_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let events = if stream_call == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }), + Ok(StreamEvent::ToolCallInputDelta { + index: 0, + partial_json: "{}".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ResponseContent::ToolUse { + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }), + ] + } else { + vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: "Done.".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("Done.".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }), + ] + }; + Ok(Box::pin(stream::iter(events))) + } + + fn name(&self) -> &str { + "auto-review-test-provider" + } +} + #[derive(Default)] struct SteerCapturingProvider { stream_requests: Mutex>, @@ -636,6 +833,96 @@ async fn turn_start_rejects_missing_skill_references() -> Result<()> { Ok(()) } +#[tokio::test] +async fn auto_review_approval_executes_mutating_tool_without_user_prompt() -> Result<()> { + let temp_dir = TempDir::new()?; + let user_skill_root = temp_dir.path().join("user-skills"); + let workspace_root = temp_dir.path().join("workspace"); + let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reviewer_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let runtime = build_runtime_with_registry( + temp_dir.path(), + user_skill_root, + Some(workspace_root.clone()), + Arc::new(AutoReviewProvider { + decision: "approve", + reviewer_calls: Arc::clone(&reviewer_calls), + stream_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }), + auto_review_registry(Arc::clone(&tool_calls)), + ); + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + let session_id = start_session(&runtime, connection_id, &workspace_root).await?; + update_permissions_to_auto_review(&runtime, connection_id, session_id).await?; + + start_auto_review_turn(&runtime, connection_id, session_id).await?; + wait_for_turn_completed(&mut notifications_rx).await?; + + assert_eq!(reviewer_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + Ok(()) +} + +#[tokio::test] +async fn auto_review_deny_blocks_mutating_tool() -> Result<()> { + let temp_dir = TempDir::new()?; + let user_skill_root = temp_dir.path().join("user-skills"); + let workspace_root = temp_dir.path().join("workspace"); + let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reviewer_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let runtime = build_runtime_with_registry( + temp_dir.path(), + user_skill_root, + Some(workspace_root.clone()), + Arc::new(AutoReviewProvider { + decision: "deny", + reviewer_calls: Arc::clone(&reviewer_calls), + stream_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }), + auto_review_registry(Arc::clone(&tool_calls)), + ); + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + let session_id = start_session(&runtime, connection_id, &workspace_root).await?; + update_permissions_to_auto_review(&runtime, connection_id, session_id).await?; + + start_auto_review_turn(&runtime, connection_id, session_id).await?; + wait_for_turn_completed(&mut notifications_rx).await?; + + assert_eq!(reviewer_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + Ok(()) +} + +#[tokio::test] +async fn auto_review_uncertain_falls_back_to_user_approval() -> Result<()> { + let temp_dir = TempDir::new()?; + let user_skill_root = temp_dir.path().join("user-skills"); + let workspace_root = temp_dir.path().join("workspace"); + let tool_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let reviewer_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let runtime = build_runtime_with_registry( + temp_dir.path(), + user_skill_root, + Some(workspace_root.clone()), + Arc::new(AutoReviewProvider { + decision: "uncertain", + reviewer_calls: Arc::clone(&reviewer_calls), + stream_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + }), + auto_review_registry(Arc::clone(&tool_calls)), + ); + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + let session_id = start_session(&runtime, connection_id, &workspace_root).await?; + update_permissions_to_auto_review(&runtime, connection_id, session_id).await?; + + start_auto_review_turn(&runtime, connection_id, session_id).await?; + wait_for_approval_request(&mut notifications_rx).await?; + + assert_eq!(reviewer_calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + Ok(()) +} + #[tokio::test] async fn turn_steer_injects_resolved_skill_into_next_model_request() -> Result<()> { let temp_dir = TempDir::new()?; diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 235cdc8..f2868e5 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -17,15 +17,34 @@ chrono = { workspace = true } futures = { workspace = true } glob = { workspace = true } portable-pty = "0.9" +rand = { workspace = true } regex = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +shlex = { workspace = true } smol_str = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +[target.'cfg(windows)'.dependencies] +filedescriptor = "0.8.3" +lazy_static = { workspace = true } +shared_library = "0.1.9" +winapi = { version = "0.3.9", features = [ + "handleapi", + "minwinbase", + "ntdef", + "ntstatus", + "processthreadsapi", + "synchapi", + "winbase", + "wincon", + "winerror", + "winnt", +] } + [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/tools/src/apply_patch.rs b/crates/tools/src/apply_patch.rs index c269636..46141c6 100644 --- a/crates/tools/src/apply_patch.rs +++ b/crates/tools/src/apply_patch.rs @@ -2,51 +2,12 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; -use async_trait::async_trait; use serde_json::json; use tokio::fs; use tracing::debug; -use crate::Tool; -use crate::ToolContext; use crate::ToolOutput; -const DESCRIPTION: &str = include_str!("apply_patch.txt"); - -pub struct ApplyPatchTool; - -#[async_trait] -impl Tool for ApplyPatchTool { - fn name(&self) -> &str { - "apply_patch" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "patchText": { - "type": "string", - "description": "The full patch text that describes all changes to be made" - } - }, - "required": ["patchText"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - exec_apply_patch(&ctx.cwd, &ctx.session_id, input).await - } -} - pub(crate) async fn exec_apply_patch( cwd: &std::path::Path, session_id: &str, @@ -743,24 +704,19 @@ fn normalized_lines(content: &str) -> Vec { #[cfg(test)] mod tests { - use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; - use devo_safety::legacy_permissions::PermissionMode; - use devo_safety::legacy_permissions::RuleBasedPolicy; use pretty_assertions::assert_eq; use serde_json::json; - use super::ApplyPatchTool; use super::HunkLine; use super::PatchHunk; use super::PatchKind; use super::apply_hunks; + use super::exec_apply_patch; use super::parse_patch; use super::resolve_relative; - use crate::Tool; - use crate::ToolContext; fn unique_temp_dir(name: &str) -> std::path::PathBuf { let nanos = SystemTime::now() @@ -772,14 +728,6 @@ mod tests { path } - fn make_ctx(cwd: std::path::PathBuf) -> ToolContext { - ToolContext { - cwd, - permissions: Arc::new(RuleBasedPolicy::new(PermissionMode::AutoApprove)), - session_id: "test-session".into(), - } - } - #[test] fn parse_patch_supports_all_change_kinds() { let patch = parse_patch( @@ -967,13 +915,12 @@ hello std::fs::write(cwd.join("update.txt"), "old\n").expect("write update file"); std::fs::write(cwd.join("from.txt"), "before\n").expect("write move source"); std::fs::write(cwd.join("delete.txt"), "remove me\n").expect("write delete source"); - let ctx = make_ctx(cwd.clone()); - let output = ApplyPatchTool - .execute( - &ctx, - json!({ - "patchText": "*** Begin Patch + let output = exec_apply_patch( + &cwd, + "test-session", + json!({ + "patchText": "*** Begin Patch *** Add File: add.txt +hello *** Update File: update.txt @@ -987,10 +934,10 @@ hello -before +after *** End Patch" - }), - ) - .await - .expect("execute apply_patch"); + }), + ) + .await + .expect("execute apply_patch"); assert!(!output.is_error); assert!( @@ -1030,477 +977,4 @@ hello assert_eq!(files[3]["additions"], 1); assert_eq!(files[3]["deletions"], 1); } - - #[tokio::test] - async fn execute_given_patch() { - let content = r#"use std::{ - fs::File, - io::{BufRead, BufReader, Read}, - path::{Path, PathBuf}, -}; - -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("read.txt"); -const MAX_LINE_LENGTH: usize = 2000; -const MAX_LINE_SUFFIX: &str = "... (line truncated to 2000 chars)"; -const MAX_BYTES: usize = 50 * 1024; -const MAX_BYTES_LABEL: &str = "50 KB"; - -pub struct ReadTool; - -#[async_trait] -impl Tool for ReadTool { - fn name(&self) -> &str { - "read" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "filePath": { - "type": "string", - "description": "The absolute path to the file or directory to read" - }, - "offset": { - "type": "integer", - "description": "The line number to start reading from (1-indexed, default 1)" - }, - "limit": { - "type": "integer", - "description": "The maximum number of lines to read (no limit by default)" - } - }, - "required": ["filePath"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let mut filepath = input["filePath"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'filePath' field"))? - .to_string(); - let offset = input["offset"].as_u64().map(|value| value as usize); - let limit = input["limit"].as_u64().map(|value| value as usize); - - if let Some(offset) = offset { - if offset < 1 { - return Ok(ToolOutput::error( - "offset must be greater than or equal to 1", - )); - } - } - - if !Path::new(&filepath).is_absolute() { - filepath = ctx.cwd.join(&filepath).to_string_lossy().to_string(); - } - - let path = PathBuf::from(&filepath); - if !path.exists() { - return Ok(ToolOutput::error(missing_file_message(&filepath))); - } - - if path.is_dir() { - return read_directory( - &path, limit.unwrap_or(usize::MAX), - offset.unwrap_or(1), - ); - } - - if is_binary_file(&path)? { - return Ok(ToolOutput::error(format!( - "Cannot read binary file: {}", - path.display() - ))); - } - - read_file( - &path, - limit.unwrap_or(usize::MAX), - offset.unwrap_or(1), - ) - } -} - -fn read_directory(path: &Path, limit: usize, offset: usize) -> anyhow::Result { - let mut items = std::fs::read_dir(path)? - .flatten() - .map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - if is_dir { format!("{name}/") } else { name } - }) - .collect::>(); - items.sort_unstable_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - - let start = offset.saturating_sub(1); - let sliced = items - .iter() - .skip(start) - .take(limit) - .cloned() - .collect::>(); - let truncated = start + sliced.len() < items.len(); - let preview = sliced - .iter() - .take(20) - .cloned() - .collect::>() - .join("\n"); - - let output = [ - format!("{}", path.display()), - "directory".to_string(), - "".to_string(), - sliced.join("\n"), - if truncated { - format!("\n(Showing {} of {} entries. Use 'offset' parameter to read beyond entry {})", sliced.len(), items.len(), offset + sliced.len()) - } else { - format!("\n({} entries)", items.len()) - }, - "".to_string(), - ] - .join("\n"); - - Ok(ToolOutput { - content: output, - is_error: false, - metadata: Some(json!({ - "preview": preview, - "truncated": truncated, - "loaded": [] - })), - }) -} - -fn read_file(path: &Path, limit: usize, offset: usize) -> anyhow::Result { - let file = File::open(path)?; - let reader = BufReader::new(file); - let start = offset.saturating_sub(1); - let mut raw = Vec::new(); - let mut bytes = 0usize; - let mut count = 0usize; - let mut cut = false; - let mut more = false; - - for line in reader.lines() { - let mut line = line?; - count += 1; - if count <= start { - continue; - } - if raw.len() >= limit { - more = true; - continue; - } - if line.len() > MAX_LINE_LENGTH { - line.truncate(MAX_LINE_LENGTH); - line.push_str(MAX_LINE_SUFFIX); - } - let size = line.len() + if raw.is_empty() { 0 } else { 1 }; - if bytes + size > MAX_BYTES { - cut = true; - more = true; - break; - } - raw.push(line); - bytes += size; - } - - if count < offset && !(count == 0 && offset == 1) { - return Ok(ToolOutput::error(format!( - "Offset {} is out of range for this file ({} lines)", - offset, count - ))); - } - - let mut output = format!( - "{}\nfile\n\n", - path.display() - ); - for (index, line) in raw.iter().enumerate() { - output.push_str(&format!("{}: {}\n", offset + index, line)); - } - - let last = offset + raw.len().saturating_sub(1); - let next = last + 1; - if cut { - output.push_str(&format!( - "\n(Output capped at {}. Showing lines {}-{}. Use offset={} to continue.)", - MAX_BYTES_LABEL, offset, last, next - )); - } else if more { - output.push_str(&format!("\n(Showing lines {}-{} of {}. Use offset={} to continue.)", offset, last, count, next)) - } else { - output.push_str(&format!("\n(End of file - total {} lines)", count)) - } - output.push_str("\n"); - - Ok(ToolOutput { - content: output, - is_error: false, - metadata: Some(json!({ - "preview": raw.iter().take(20).cloned().collect::>().join("\n"), - "truncated": cut || more, - "loaded": [] - })), - }) -} - -fn is_binary_file(path: &Path) -> anyhow::Result { - let ext = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if matches!( - ext.as_str(), - "zip" - | "tar" - | "gz" - | "exe" - | "dll" - | "so" - | "class" - | "jar" - | "war" - | "7z" - | "doc" - | "docx" - | "xls" - | "xlsx" - | "ppt" - | "pptx" - | "odt" - | "ods" - | "odp" - | "bin" - | "dat" - | "obj" - | "o" - | "a" - | "lib" - | "wasm" - | "pyc" - | "pyo" - ) { - return Ok(true); - } - - let mut file = File::open(path)?; - let size = file.metadata()?.len() as usize; - if size == 0 { - return Ok(false); - } - - let sample_size = size.min(4096); - let mut bytes = vec![0u8; sample_size]; - let read = file.read(&mut bytes)?; - if read == 0 { - return Ok(false); - } - - let mut non_printable = 0usize; - for byte in bytes.iter().take(read) { - if *byte == 0 { - return Ok(true); - } - if *byte < 9 || (*byte > 13 && *byte < 32) { - non_printable += 1; - } - } - - Ok((non_printable as f64) / (read as f64) > 0.3) -} - -fn missing_file_message(filepath: &str) -> String { - let path = Path::new(filepath); - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - let base = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(filepath); - - let suggestions = std::fs::read_dir(dir) - .map(|entries| { - entries - .flatten() - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| { - name.to_lowercase().contains(&base.to_lowercase()) - || base.to_lowercase().contains(&name.to_lowercase()) - }) - .take(3) - .collect::>() - }) - .unwrap_or_default(); - - if suggestions.is_empty() { - format!("File not found: {filepath}") - } else { - format!( - "File not found: {filepath}\n\nDid you mean one of these?\n{}", - suggestions - .into_iter() - .map(|item| dir.join(item).to_string_lossy().to_string()) - .collect::>() - .join("\n") - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{ - env, - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, - }; - - fn create_temp_dir(prefix: &str) -> PathBuf { - let mut path = env::temp_dir(); - let ticks = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - path.push(format!("devo-tools-read-{prefix}-{ticks}")); - let _ = fs::remove_dir_all(&path); - fs::create_dir_all(&path).unwrap(); - path - } - - fn write_lines(path: &Path, lines: &[&str]) { - let mut file = File::create(path).unwrap(); - for line in lines { - writeln!(file, "{line}").unwrap(); - } - } - - #[test] - fn read_directory_sorts_entries_and_reports_truncation() { - let dir = create_temp_dir("dir"); - File::create(dir.join("b.txt")).unwrap(); - File::create(dir.join("a.txt")).unwrap(); - fs::create_dir_all(dir.join("subdir")).unwrap(); - - let output = read_directory(&dir, 1, 2).unwrap(); - assert!(output.content.contains("directory")); - assert!(output.content.contains("b.txt")); - assert!( - output.content.contains( - "(Showing 1 of 3 entries. Use 'offset' parameter to read beyond entry 3)" - ) - ); - - let metadata = output.metadata.unwrap(); - assert!(metadata.get("truncated").and_then(|value| value.as_bool()) == Some(true)); - } - - #[test] - fn read_file_applies_limit_and_reports_more() { - let dir = create_temp_dir("file"); - let path = dir.join("sample.txt"); - write_lines(&path, &["line1", "line2", "line3", "line4", "line5"]); - - let output = read_file(&path, 2, 2).unwrap(); - assert!(!output.is_error); - assert!(output.content.contains("2: line2")); - assert!(output.content.contains("3: line3")); - assert!( - output - .content - .contains("(Showing lines 2-3 of 5. Use offset=4 to continue.)") - ); - - let metadata = output.metadata.unwrap(); - assert!(metadata.get("truncated").and_then(|value| value.as_bool()) == Some(true)); - } - - #[test] - fn read_file_reports_offset_out_of_range() { - let dir = create_temp_dir("error"); - let path = dir.join("short.txt"); - write_lines(&path, &["hello", "world"]); - - let output = read_file(&path, 10, 5).unwrap(); - assert!(output.is_error); - assert!(output.content.contains("Offset 5 is out of range")); - } - - #[test] - fn is_binary_file_detects_null_bytes() { - let dir = create_temp_dir("binary"); - let path = dir.join("payload.bin"); - fs::write(&path, &[0u8, 1, 2]).unwrap(); - - assert!(is_binary_file(&path).unwrap()); - } - - #[test] - fn missing_file_message_includes_suggestions() { - let dir = create_temp_dir("missing"); - let target = dir.join("example.txt"); - write_lines(&target, &["content"]); - - let missing = dir.join("example"); - let message = missing_file_message(&missing.to_string_lossy()); - assert!(message.contains("Did you mean")); - assert!(message.contains("example.txt")); - } -} -"#; - let cwd = unique_temp_dir("execute"); - std::fs::write(cwd.join("read.rs"), content).expect("write update file"); - - let ctx = make_ctx(cwd); - - let patch = r#"*** Begin Patch -*** Update File: read.rs -@@ use std::{ - fs::File, - io::{BufRead, BufReader, Read}, - path::{Path, PathBuf}, - }; - - use async_trait::async_trait; - use serde_json::json; - - use crate::{Tool, ToolContext, ToolOutput}; - - const DESCRIPTION: &str = include_str!("read.txt"); --const MAX_LINE_LENGTH: usize = 2000; --const MAX_LINE_SUFFIX: &str = "... (line truncated to 2000 chars)"; --const MAX_BYTES: usize = 50 * 1024; --const MAX_BYTES_LABEL: &str = "50 KB"; - - pub struct ReadTool; -*** End Patch"#; - - let output = ApplyPatchTool - .execute( - &ctx, - json!({ - "patchText": patch - }), - ) - .await - .expect("execute apply_patch"); - - assert_eq!(output.is_error, false); - } } diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs deleted file mode 100644 index c3339a5..0000000 --- a/crates/tools/src/bash.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::shell_exec::{ - ShellExecRequest, default_max_output_tokens, default_timeout_ms, default_yield_time_ms, - execute_shell_command, platform_shell_program, -}; -use crate::{Tool, ToolContext, ToolOutput}; -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; - -const DESCRIPTION: &str = include_str!("bash.txt"); -const DESCRIPTION_MAX_BYTES_LABEL: &str = "64 KB"; - -/// Execute shell commands. -/// -/// This is the most powerful built-in tool. It runs commands in a child -/// process and captures stdout/stderr. -pub struct BashTool; - -#[async_trait] -impl Tool for BashTool { - fn name(&self) -> &str { - "bash" - } - - /// TODO: the shell tool should be re implemented. - fn description(&self) -> &str { - let chaining = if cfg!(windows) { - "If commands depend on each other and must run sequentially, use a single PowerShell command string. In Windows PowerShell 5.1, do not rely on Bash chaining semantics like `cmd1 && cmd2`; prefer `cmd1; if ($?) { cmd2 }` when the later command depends on earlier success." - } else { - "If commands depend on each other and must run sequentially, use a single shell command and chain with `&&` when later commands depend on earlier success." - }; - Box::leak( - DESCRIPTION - .replace( - "${directory}", - &std::env::current_dir() - .map_or_else(|_| ".".to_string(), |path| path.display().to_string()), - ) - .replace("${os}", std::env::consts::OS) - .replace("${shell}", platform_shell_program(true)) - .replace("${chaining}", chaining) - .replace("${maxBytes}", DESCRIPTION_MAX_BYTES_LABEL) - .into_boxed_str(), - ) - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute in the selected platform shell" - }, - "cmd": { - "type": "string", - "description": "Alias for command" - }, - "timeout": { - "type": "integer", - "description": "Optional timeout in milliseconds" - }, - "workdir": { - "type": "string", - "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in 5-10 words." - }, - "shell": { - "type": "string", - "description": "Optional shell binary to launch. Defaults to the user's default shell." - }, - "tty": { - "type": "boolean", - "description": "Whether to allocate a TTY for the command. Defaults to false." - }, - "login": { - "type": "boolean", - "description": "Whether to run the shell with login shell semantics. Defaults to true." - }, - "yield_time_ms": { - "type": "integer", - "description": "How long to wait (in milliseconds) for output before yielding." - }, - "max_output_tokens": { - "type": "integer", - "description": "Maximum number of tokens to return. Excess output will be truncated." - } - }, - "required": ["command"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let command = input - .get("command") - .or_else(|| input.get("cmd")) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| anyhow::anyhow!("missing 'command' field"))?; - - let timeout_ms = input["timeout"].as_u64().unwrap_or(default_timeout_ms()); - let workdir = input["workdir"] - .as_str() - .map(PathBuf::from) - .unwrap_or_else(|| ctx.cwd.clone()); - let description = input["description"] - .as_str() - .unwrap_or("shell command") - .to_string(); - let shell_override = input["shell"].as_str().map(ToOwned::to_owned); - let tty = input["tty"].as_bool().unwrap_or(false); - let login = input["login"].as_bool().unwrap_or(true); - let yield_time_ms = input["yield_time_ms"] - .as_u64() - .unwrap_or(default_yield_time_ms()); - let max_output_tokens = input["max_output_tokens"] - .as_u64() - .map(|value| value as usize) - .unwrap_or(default_max_output_tokens()); - - execute_shell_command( - ShellExecRequest { - command: command.to_string(), - workdir, - description, - shell_override, - tty, - login, - timeout_ms, - yield_time_ms, - max_output_tokens, - }, - None, - ) - .await - } - - fn is_read_only(&self) -> bool { - false - } -} - -#[cfg(test)] -mod tests { - use crate::shell_exec::{merge_streams, platform_shell_program, preview, truncate_output}; - - #[test] - fn resolve_shell_defaults_to_platform_shell_login() { - assert_eq!( - platform_shell_program(true), - if cfg!(windows) { "powershell" } else { "bash" } - ); - } - - #[test] - fn preview_truncates_long_text() { - let long = "a".repeat(30_001); - let result = preview(&long); - assert!(result.ends_with("\n\n...")); - } - - #[test] - fn truncate_output_handles_zero_tokens() { - assert_eq!(truncate_output("text", 0), ""); - } - - #[test] - fn truncate_output_limits_length() { - let input = "a".repeat(200); - let result = truncate_output(&input, 10); - assert!(result.ends_with("\n\n... [truncated]")); - assert!(result.len() < input.len()); - } - - #[test] - fn merge_streams_combines_stdout_and_stderr() { - let result = merge_streams("out", "err"); - assert!(result.contains("out")); - assert!(result.contains("[stderr]")); - assert!(result.contains("err")); - } - - #[test] - fn merge_streams_no_output() { - assert_eq!(merge_streams("", ""), "(no output)"); - } - - #[test] - fn truncate_output_keeps_short_text() { - let input = "short"; - assert_eq!(truncate_output(input, 10), input); - } -} diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs deleted file mode 100644 index 2cb11df..0000000 --- a/crates/tools/src/context.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use devo_safety::legacy_permissions::PermissionPolicy; - -/// The execution context provided to every tool call. -/// -/// Instead of a monolithic context object, tools receive only the -/// dependencies they actually need. This makes tool implementations -/// easier to test and reason about. -pub struct ToolContext { - /// Current working directory for the session. - pub cwd: PathBuf, - /// The permission policy in effect. - pub permissions: Arc, - /// Session-level metadata tools can use for state. - pub session_id: String, -} diff --git a/crates/tools/src/file_write.rs b/crates/tools/src/file_write.rs deleted file mode 100644 index 263f1b0..0000000 --- a/crates/tools/src/file_write.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::path::PathBuf; - -use crate::{Tool, ToolContext, ToolOutput}; -use async_trait::async_trait; -use devo_safety::legacy_permissions::{PermissionDecision, PermissionRequest, ResourceKind}; -use serde_json::json; -use tracing::info; - -const DESCRIPTION: &str = include_str!("write.txt"); - -/// Write content to a file, creating directories as needed. -pub struct FileWriteTool; - -#[async_trait] -impl Tool for FileWriteTool { - fn name(&self) -> &str { - "write" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "filePath": { - "type": "string", - "description": "The absolute or relative file path to write" - }, - "content": { - "type": "string", - "description": "The content to write to the file" - } - }, - "required": ["filePath", "content"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let path_str = input["filePath"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'filePath' field"))?; - let content = input["content"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'content' field"))?; - - let path = resolve_path(&ctx.cwd, path_str); - - let perm_request = PermissionRequest { - tool_name: "file_write".into(), - resource: ResourceKind::FileWrite, - description: format!("write file: {}", path.display()), - target: Some(path.to_string_lossy().to_string()), - }; - - match ctx.permissions.check(&perm_request).await { - PermissionDecision::Allow => {} - PermissionDecision::Deny { reason } => { - return Ok(ToolOutput::error(format!("permission denied: {}", reason))); - } - PermissionDecision::Ask { message } => { - return Ok(ToolOutput::error(format!( - "permission required — run with --permission interactive to approve: {}", - message - ))); - } - } - - info!(path = %path.display(), bytes = content.len(), "writing file"); - - if let Some(parent) = path.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await - { - return Ok(ToolOutput::error(format!( - "failed to create directories: {}", - e - ))); - } - - match tokio::fs::write(&path, content).await { - Ok(_) => Ok(ToolOutput::success(format!( - "wrote {} bytes to {}", - content.len(), - path.display() - ))), - Err(e) => Ok(ToolOutput::error(format!("failed to write file: {}", e))), - } - } - - fn is_read_only(&self) -> bool { - false - } -} - -fn resolve_path(cwd: &std::path::Path, path: &str) -> PathBuf { - let p = PathBuf::from(path); - if p.is_absolute() { p } else { cwd.join(p) } -} diff --git a/crates/tools/src/glob.rs b/crates/tools/src/glob.rs deleted file mode 100644 index 6006bd1..0000000 --- a/crates/tools/src/glob.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crate::{Tool, ToolContext, ToolOutput}; -use async_trait::async_trait; -use serde_json::json; -use tracing::debug; - -const DESCRIPTION: &str = include_str!("glob.txt"); - -/// Find files matching a glob pattern, sorted by modification time. -pub struct GlobTool; - -#[async_trait] -impl Tool for GlobTool { - fn name(&self) -> &str { - "glob" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Glob pattern to match (e.g. \"**/*.rs\")" - }, - "path": { - "type": "string", - "description": "Directory to search in (default: cwd)" - } - }, - "required": ["pattern"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let pattern = input["pattern"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'pattern' field"))?; - - let base = match input["path"].as_str() { - Some(p) => { - let pb = std::path::PathBuf::from(p); - if pb.is_absolute() { - pb - } else { - ctx.cwd.join(pb) - } - } - None => ctx.cwd.clone(), - }; - - debug!(pattern, base = %base.display(), "glob search"); - - let full_pattern = base.join(pattern); - let pattern_str = full_pattern.to_string_lossy(); - - let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); - - match glob::glob(&pattern_str) { - Ok(paths) => { - for entry in paths.flatten() { - let mtime = entry - .metadata() - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - entries.push((entry, mtime)); - } - } - Err(e) => return Ok(ToolOutput::error(format!("invalid glob pattern: {}", e))), - } - - // Sort newest first - entries.sort_by(|a, b| b.1.cmp(&a.1)); - - if entries.is_empty() { - return Ok(ToolOutput::success("(no matches)")); - } - - let lines: Vec = entries - .iter() - .map(|(p, _)| p.to_string_lossy().to_string()) - .collect(); - - Ok(ToolOutput::success(lines.join("\n"))) - } - - fn is_read_only(&self) -> bool { - true - } -} diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs deleted file mode 100644 index b6c97f3..0000000 --- a/crates/tools/src/grep.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::{Tool, ToolContext, ToolOutput}; -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tracing::debug; - -const DESCRIPTION: &str = include_str!("grep.txt"); - -/// Search file contents with a regular expression. -pub struct GrepTool; - -#[async_trait] -impl Tool for GrepTool { - fn name(&self) -> &str { - "grep" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Regular expression to search for" - }, - "path": { - "type": "string", - "description": "Directory or file to search (default: cwd)" - }, - "glob": { - "type": "string", - "description": "Only search files matching this glob (e.g. \"*.rs\")" - }, - "case_insensitive": { - "type": "boolean", - "description": "Case-insensitive matching (default: false)" - } - }, - "required": ["pattern"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let pattern_str = input["pattern"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'pattern' field"))?; - - let case_insensitive = input["case_insensitive"].as_bool().unwrap_or(false); - - let re = { - let mut builder = regex::RegexBuilder::new(pattern_str); - builder.case_insensitive(case_insensitive); - match builder.build() { - Ok(r) => r, - Err(e) => return Ok(ToolOutput::error(format!("invalid regex: {}", e))), - } - }; - - let base = match input["path"].as_str() { - Some(p) => { - let pb = PathBuf::from(p); - if pb.is_absolute() { - pb - } else { - ctx.cwd.join(pb) - } - } - None => ctx.cwd.clone(), - }; - - let glob_pattern = input["glob"].as_str(); - - debug!(pattern = pattern_str, base = %base.display(), "grep search"); - - let files = collect_files(&base, glob_pattern); - - let mut results: Vec = Vec::new(); - const MAX_RESULTS: usize = 500; - - 'outer: for file in &files { - let content = match tokio::fs::read_to_string(file).await { - Ok(c) => c, - Err(_) => continue, - }; - for (lineno, line) in content.lines().enumerate() { - if re.is_match(line) { - results.push(format!( - "{}:{}:{}", - file.to_string_lossy(), - lineno + 1, - line - )); - if results.len() >= MAX_RESULTS { - results.push(format!("(truncated at {} matches)", MAX_RESULTS)); - break 'outer; - } - } - } - } - - if results.is_empty() { - return Ok(ToolOutput::success("(no matches)")); - } - - Ok(ToolOutput::success(results.join("\n"))) - } - - fn is_read_only(&self) -> bool { - true - } -} - -fn collect_files(base: &std::path::Path, glob_pattern: Option<&str>) -> Vec { - let pattern = match glob_pattern { - Some(g) => base.join("**").join(g).to_string_lossy().to_string(), - None => base.join("**").join("*").to_string_lossy().to_string(), - }; - - glob::glob(&pattern) - .into_iter() - .flatten() - .flatten() - .filter(|p| p.is_file()) - .collect() -} diff --git a/crates/tools/src/handlers/exec_command.rs b/crates/tools/src/handlers/exec_command.rs index 6cae2d2..864b14e 100644 --- a/crates/tools/src/handlers/exec_command.rs +++ b/crates/tools/src/handlers/exec_command.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use async_trait::async_trait; +use uuid::Uuid; +use crate::apply_patch::exec_apply_patch; use crate::errors::ToolExecutionError; use crate::events::ToolProgressSender; use crate::handler_kind::ToolHandlerKind; @@ -9,7 +11,10 @@ use crate::invocation::{FunctionToolOutput, ToolInvocation, ToolOutput}; use crate::tool_handler::ToolHandler; use crate::unified_exec::process::{UnifiedExecProcess, collect_output}; use crate::unified_exec::store::ProcessStore; -use crate::unified_exec::{ExecCommandArgs, ProcessOutput, WriteStdinArgs}; +use crate::unified_exec::{ExecCommandArgs, ProcessOutput, WARNING_PROCESSES, WriteStdinArgs}; + +const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; +const UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES: usize = 8_192; pub struct ExecCommandHandler { store: Arc, @@ -45,7 +50,7 @@ impl ToolHandler for ExecCommandHandler { workdir: invocation.input["workdir"].as_str().map(|s| s.to_string()), shell: invocation.input["shell"].as_str().map(|s| s.to_string()), login: invocation.input["login"].as_bool().unwrap_or(true), - tty: invocation.input["tty"].as_bool().unwrap_or(true), + tty: invocation.input["tty"].as_bool().unwrap_or(false), yield_time_ms: invocation.input["yield_time_ms"] .as_u64() .unwrap_or(crate::unified_exec::DEFAULT_YIELD_MS), @@ -57,7 +62,14 @@ impl ToolHandler for ExecCommandHandler { let cwd = invocation.input["workdir"] .as_str() - .map(std::path::PathBuf::from) + .map(|path| { + let path = std::path::PathBuf::from(path); + if path.is_absolute() { + path + } else { + invocation.cwd.join(path) + } + }) .unwrap_or_else(|| invocation.cwd.clone()); if !cwd.exists() { @@ -67,33 +79,101 @@ impl ToolHandler for ExecCommandHandler { )))); } - let (proc, _broadcast_rx) = - UnifiedExecProcess::spawn(0, &args.cmd, &cwd, args.shell.as_deref(), args.login) - .map_err(|e| ToolExecutionError::ExecutionFailed { - message: format!("failed to spawn process: {e}"), - })?; + if is_raw_apply_patch_body(&args.cmd) { + return Ok(Box::new(FunctionToolOutput::error( + "apply_patch verification failed: patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]", + ))); + } + + if let Some((patch_cwd, patch_text)) = apply_patch_command(&args.cmd, &cwd) { + let output = exec_apply_patch( + &patch_cwd, + &invocation.session_id, + serde_json::json!({ "patchText": patch_text }), + ) + .await + .map_err(|e| ToolExecutionError::ExecutionFailed { + message: e.to_string(), + })?; + let content = format_apply_patch_intercept_response(&output.content); + let output = if output.is_error { + FunctionToolOutput::error(content) + } else { + FunctionToolOutput::success(content) + }; + return Ok(Box::new(output)); + } + + let Some(session_id) = self.store.reserve_process_id().await else { + return Ok(Box::new(FunctionToolOutput::error(format!( + "max unified exec processes ({}) reached; cannot allocate process", + crate::unified_exec::MAX_PROCESSES + )))); + }; + + let (proc, _broadcast_rx) = match UnifiedExecProcess::spawn( + session_id, + &args.cmd, + &cwd, + args.shell.as_deref(), + args.login, + args.tty, + ) { + Ok(spawned) => spawned, + Err(error) => { + self.store.release_reserved(session_id).await; + return Err(ToolExecutionError::ExecutionFailed { + message: format!("failed to spawn process: {error}"), + }); + } + }; if let Some(ref sender) = progress { let mut progress_rx = proc.subscribe(); let s = sender.clone(); tokio::spawn(async move { + let mut emitted_deltas = 0usize; while let Ok(bytes) = progress_rx.recv().await { - let text = String::from_utf8_lossy(&bytes).into_owned(); - if s.send(text).is_err() { - break; + for text in progress_delta_chunks(&bytes) { + if emitted_deltas >= MAX_EXEC_OUTPUT_DELTAS_PER_CALL { + return; + } + emitted_deltas += 1; + if s.send(text).is_err() { + return; + } } } }); } let proc = Arc::new(proc); - let session_id = self.store.allocate(Arc::clone(&proc)).await; + self.store + .insert_reserved(session_id, Arc::clone(&proc)) + .await; let mut rx = proc.subscribe(); - let output = - collect_output(&mut rx, &proc, args.yield_time_ms, args.max_output_tokens).await; + let output = collect_output( + &mut rx, + &proc, + crate::unified_exec::clamp_exec_yield_time(args.yield_time_ms), + args.max_output_tokens, + ) + .await; + let warning = if output.exit_code.is_some() { + self.store.remove(session_id).await; + None + } else { + let process_count = self.store.len().await; + (process_count >= WARNING_PROCESSES).then(|| open_process_warning(process_count)) + }; - let response = format_exec_response(&output, Some(session_id)); + let response = format_exec_response( + &output, + Some(session_id), + Some(generate_chunk_id()), + warning.as_deref(), + ); Ok(Box::new(FunctionToolOutput::success(response))) } } @@ -142,31 +222,61 @@ impl ToolHandler for WriteStdinHandler { })?; if !args.chars.is_empty() { - proc.write_stdin(&args.chars) - .map_err(|e| ToolExecutionError::ExecutionFailed { - message: format!("write_stdin failed: {e}"), - })?; + if !proc.tty() { + return Err(ToolExecutionError::ExecutionFailed { + message: "stdin is closed for this session".to_string(), + }); + } + if let Err(error) = proc.write_stdin(&args.chars) + && proc.is_running() + && proc.exit_code().is_none() + { + return Err(ToolExecutionError::ExecutionFailed { + message: format!("write_stdin failed: {error}"), + }); + } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } let mut rx = proc.subscribe(); - let output = - collect_output(&mut rx, &proc, args.yield_time_ms, args.max_output_tokens).await; - - if output.exit_code.is_some() && output.output.is_empty() { + let output = collect_output( + &mut rx, + &proc, + crate::unified_exec::clamp_write_stdin_yield_time(args.yield_time_ms, &args.chars), + args.max_output_tokens, + ) + .await; + + if output.exit_code.is_some() { self.store.remove(args.session_id).await; } - let response = format_exec_response(&output, None); + let response = format_exec_response( + &output, + Some(args.session_id), + Some(generate_chunk_id()), + /*warning*/ None, + ); Ok(Box::new(FunctionToolOutput::success(response))) } } -fn format_exec_response(output: &ProcessOutput, session_id: Option) -> String { +fn format_exec_response( + output: &ProcessOutput, + session_id: Option, + chunk_id: Option, + warning: Option<&str>, +) -> String { let mut parts = Vec::new(); - parts.push(format!("Wall time: {:.1} seconds", output.wall_time_secs)); + if let Some(chunk_id) = chunk_id + && !chunk_id.is_empty() + { + parts.push(format!("Chunk ID: {chunk_id}")); + } + + parts.push(format!("Wall time: {:.4} seconds", output.wall_time_secs)); if let Some(code) = output.exit_code { parts.push(format!("Process exited with code {code}")); @@ -176,20 +286,136 @@ fn format_exec_response(output: &ProcessOutput, session_id: Option) -> Stri { parts.push(format!("Process running with session ID {sid}")); } - - if output.truncated { - parts.push("Output (truncated):".to_string()); - } else { - parts.push("Output:".to_string()); + if let Some(warning) = warning { + parts.push(warning.to_string()); } + + parts.push(format!( + "Original token count: {}", + output.original_token_count + )); + parts.push("Output:".to_string()); parts.push(output.output.clone()); parts.join("\n") } +fn generate_chunk_id() -> String { + Uuid::new_v4().to_string().chars().take(6).collect() +} + +fn open_process_warning(process_count: usize) -> String { + format!( + "Warning: The maximum number of unified exec processes you can keep open is {WARNING_PROCESSES} and you currently have {process_count} processes open. Reuse older processes or close them to prevent automatic pruning of old processes" + ) +} + +fn format_apply_patch_intercept_response(content: &str) -> String { + format!("Wall time: 0.0000 seconds\nOutput:\n{content}") +} + +fn progress_delta_chunks(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + let mut chunks = Vec::new(); + let mut remaining = text.as_ref(); + while !remaining.is_empty() { + let take = floor_char_boundary( + remaining, + remaining.len().min(UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES), + ); + let take = if take == 0 { + remaining + .char_indices() + .nth(1) + .map_or(remaining.len(), |(index, _)| index) + } else { + take + }; + chunks.push(remaining[..take].to_string()); + remaining = &remaining[take..]; + } + chunks +} + +fn floor_char_boundary(value: &str, mut index: usize) -> usize { + index = index.min(value.len()); + while index > 0 && !value.is_char_boundary(index) { + index -= 1; + } + index +} + +fn is_raw_apply_patch_body(command: &str) -> bool { + let trimmed = command.trim(); + trimmed.starts_with("*** Begin Patch") && trimmed.contains("*** End Patch") +} + +fn apply_patch_command( + command: &str, + cwd: &std::path::Path, +) -> Option<(std::path::PathBuf, String)> { + let trimmed = command.trim(); + if let Some(argv) = shlex::split(trimmed) + && let [cmd, patch_text] = argv.as_slice() + && (cmd == "apply_patch" || cmd == "applypatch") + { + return Some((cwd.to_path_buf(), patch_text.clone())); + } + + let (effective_cwd, script) = if let Some((cd_command, rest)) = trimmed.split_once("&&") { + let argv = shlex::split(cd_command.trim())?; + match argv.as_slice() { + [cmd, dir] if cmd == "cd" => { + let path = std::path::PathBuf::from(dir); + let path = if path.is_absolute() { + path + } else { + cwd.join(path) + }; + (path, rest.trim()) + } + _ => (cwd.to_path_buf(), trimmed), + } + } else { + (cwd.to_path_buf(), trimmed) + }; + + let mut lines = script.lines(); + let first_line = lines.next()?.trim(); + let command_name = first_line.split_whitespace().next()?; + if command_name != "apply_patch" && command_name != "applypatch" { + return None; + } + let heredoc_index = first_line.find("<<")?; + let delimiter = first_line[heredoc_index + 2..].trim(); + let delimiter = delimiter + .strip_prefix('-') + .unwrap_or(delimiter) + .trim() + .trim_matches('"') + .trim_matches('\''); + if delimiter.is_empty() { + return None; + } + + let mut patch_lines = Vec::new(); + while let Some(line) = lines.next() { + if line.trim() == delimiter { + if lines.any(|remaining| !remaining.trim().is_empty()) { + return None; + } + return Some((effective_cwd, patch_lines.join("\n"))); + } + patch_lines.push(line); + } + + None +} + #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn format_exec_response_exited() { @@ -198,12 +424,14 @@ mod tests { exit_code: Some(0), wall_time_secs: 1.5, truncated: false, + original_token_count: 3, }; - let text = format_exec_response(&output, None); - assert!(text.contains("Wall time: 1.5")); + let text = format_exec_response(&output, None, None, /*warning*/ None); + assert!(text.contains("Wall time: 1.5000")); assert!(text.contains("Process exited with code 0")); assert!(text.contains("hello world")); assert!(!text.contains("session ID")); + assert!(text.contains("Original token count: 3")); } #[test] @@ -213,8 +441,9 @@ mod tests { exit_code: None, wall_time_secs: 10.0, truncated: false, + original_token_count: 3, }; - let text = format_exec_response(&output, Some(42)); + let text = format_exec_response(&output, Some(42), None, /*warning*/ None); assert!(text.contains("Process running with session ID 42")); assert!(!text.contains("exit code")); } @@ -226,9 +455,10 @@ mod tests { exit_code: None, wall_time_secs: 5.0, truncated: true, + original_token_count: 3, }; - let text = format_exec_response(&output, Some(1)); - assert!(text.contains("Output (truncated)")); + let text = format_exec_response(&output, Some(1), None, /*warning*/ None); + assert!(text.contains("Output:")); } #[test] @@ -238,13 +468,46 @@ mod tests { exit_code: Some(0), wall_time_secs: 3.0, truncated: false, + original_token_count: 1, }; // When exit_code is Some, session_id is not shown even if provided - let text = format_exec_response(&output, Some(99)); + let text = format_exec_response(&output, Some(99), None, /*warning*/ None); assert!(text.contains("Process exited with code 0")); assert!(!text.contains("session ID")); } + #[test] + fn format_exec_response_includes_open_process_warning() { + let output = ProcessOutput { + output: "building...".into(), + exit_code: None, + wall_time_secs: 10.0, + truncated: false, + original_token_count: 3, + }; + + let text = format_exec_response( + &output, + Some(42), + None, + Some(&open_process_warning(WARNING_PROCESSES)), + ); + + assert!(text.contains("currently have 60 processes open")); + assert!(text.contains("Reuse older processes")); + } + + #[test] + fn progress_delta_chunks_caps_chunk_size_on_utf8_boundary() { + let text = "a".repeat(UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES - 1) + "😀tail"; + + let chunks = progress_delta_chunks(text.as_bytes()); + + assert_eq!(chunks.len(), 2); + assert!(chunks[0].len() <= UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES); + assert_eq!(chunks.join(""), text); + } + #[test] fn exec_command_args_missing_cmd() { let args = serde_json::json!({}); @@ -253,4 +516,153 @@ mod tests { // The cmd field is required but we can't easily test parse failure // because there's no deserialize impl for ExecCommandArgs } + + #[test] + fn apply_patch_command_extracts_heredoc() { + let command = "apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch\nPATCH\n"; + + let parsed = apply_patch_command(command, std::path::Path::new("/tmp/root")); + + assert_eq!( + parsed, + Some(( + std::path::PathBuf::from("/tmp/root"), + "*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch".to_string() + )) + ); + } + + #[test] + fn apply_patch_command_extracts_cd_heredoc() { + let command = "cd sub && apply_patch < crate::registry::Too } let process_store = Arc::new(ProcessStore::new()); + builder.set_unified_exec_store(Arc::clone(&process_store)); for (kind, name) in plan.handlers { let handler: Arc = match kind { diff --git a/crates/tools/src/invalid.rs b/crates/tools/src/invalid.rs deleted file mode 100644 index 01c3e8f..0000000 --- a/crates/tools/src/invalid.rs +++ /dev/null @@ -1,39 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -pub struct InvalidTool; - -#[async_trait] -impl Tool for InvalidTool { - fn name(&self) -> &str { - "invalid" - } - - fn description(&self) -> &str { - "Do not use" - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "tool": {"type": "string"}, - "error": {"type": "string"} - }, - "required": ["tool", "error"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let error = input["error"].as_str().unwrap_or("invalid tool arguments"); - Ok(ToolOutput::error(format!( - "The arguments provided to the tool are invalid: {error}" - ))) - } -} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 6f3d7cf..2a00f02 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,24 +15,9 @@ pub mod unified_exec; // Existing modules (tools) mod apply_patch; -mod bash; -mod context; -mod file_write; -mod glob; -mod grep; -mod invalid; -mod lsp; -mod orchestrator; -mod plan; -mod question; mod read; mod shell_exec; -mod skill; -mod task; -mod todo; mod tool; -mod webfetch; -mod websearch; // New re-exports pub use errors::*; @@ -46,75 +31,14 @@ pub use router::*; pub use tool_handler::ToolHandler; pub use tool_spec::*; -pub use apply_patch::ApplyPatchTool; -pub use bash::BashTool; -pub use context::ToolContext; -pub use file_write::FileWriteTool; -pub use glob::GlobTool; -pub use grep::GrepTool; -pub use invalid::InvalidTool; -pub use lsp::LspTool; -pub use plan::PlanTool; -pub use question::QuestionTool; -pub use read::ReadTool; -pub use skill::SkillTool; -pub use task::TaskTool; -pub use todo::TodoWriteTool; -pub use tool::{Tool, ToolOutput, ToolProgressEvent}; -pub use webfetch::WebFetchTool; -pub use websearch::WebSearchTool; - -use std::sync::Arc; +pub use tool::ToolOutput; /// Create a fully-configured tool registry with all built-in tools. -/// This is the new recommended way to bootstrap tools. +/// This is the recommended way to bootstrap tools. pub fn create_default_tool_registry() -> registry::ToolRegistry { handlers::build_registry_from_plan(&ToolPlanConfig::default()) } -#[allow(deprecated)] -pub fn register_builtin_tools(registry: &mut ToolRegistry) { - let plan = build_tool_registry_plan(&ToolPlanConfig::default()); - let mut builder = ToolRegistryBuilder::new(); - for spec in plan.specs { - builder.push_spec(spec); - } - for (kind, name) in plan.handlers { - use crate::tool_handler::ToolHandler; - let handler: Arc = match kind { - ToolHandlerKind::Bash => Arc::new(handlers::BashHandler), - ToolHandlerKind::ShellCommand => Arc::new(handlers::ShellCommandHandler), - ToolHandlerKind::Read => Arc::new(handlers::ReadHandler), - ToolHandlerKind::Write => Arc::new(handlers::WriteHandler), - ToolHandlerKind::Glob => Arc::new(handlers::GlobHandler), - ToolHandlerKind::Grep => Arc::new(handlers::GrepHandler), - ToolHandlerKind::ApplyPatch => Arc::new(handlers::ApplyPatchHandler), - ToolHandlerKind::Plan => Arc::new(handlers::PlanHandler), - ToolHandlerKind::Question => Arc::new(handlers::QuestionHandler), - ToolHandlerKind::Task => Arc::new(handlers::TaskHandler), - ToolHandlerKind::TodoWrite => Arc::new(handlers::TodoWriteHandler), - ToolHandlerKind::WebFetch => Arc::new(handlers::WebFetchHandler), - ToolHandlerKind::WebSearch => Arc::new(handlers::WebSearchHandler), - ToolHandlerKind::Skill => Arc::new(handlers::SkillHandler), - ToolHandlerKind::Lsp => Arc::new(handlers::LspHandler), - ToolHandlerKind::Invalid => Arc::new(handlers::InvalidHandler), - ToolHandlerKind::ExecCommand => { - let store = Arc::new(crate::unified_exec::store::ProcessStore::new()); - Arc::new(handlers::ExecCommandHandler::new(store)) - } - ToolHandlerKind::WriteStdin => { - let store = Arc::new(crate::unified_exec::store::ProcessStore::new()); - Arc::new(handlers::WriteStdinHandler::new(store)) - } - }; - builder.register_handler(&name, handler); - } - let new_registry = builder.build(); - registry.handlers = new_registry.handlers; - registry.specs = new_registry.specs; - registry.spec_index = new_registry.spec_index; -} - #[cfg(test)] mod tests { use super::*; @@ -198,19 +122,4 @@ mod tests { assert!(def.input_schema.is_object()); } } - - #[test] - fn register_builtin_tools_populates_registry() { - #[allow(deprecated)] - { - let mut registry = ToolRegistry::new(); - register_builtin_tools(&mut registry); - for name in &expected_tool_names_default()[..15] { - assert!( - registry.get(name).is_some(), - "expected builtin tool '{name}' to be registered" - ); - } - } - } } diff --git a/crates/tools/src/lsp.rs b/crates/tools/src/lsp.rs deleted file mode 100644 index c3db41e..0000000 --- a/crates/tools/src/lsp.rs +++ /dev/null @@ -1,43 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("lsp.txt"); - -pub struct LspTool; - -#[async_trait] -impl Tool for LspTool { - fn name(&self) -> &str { - "lsp" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "operation": {"type": "string"}, - "filePath": {"type": "string"}, - "line": {"type": "integer"}, - "character": {"type": "integer"} - }, - "required": ["operation", "filePath", "line", "character"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let operation = input["operation"].as_str().unwrap_or(""); - Ok(ToolOutput::success(format!( - "LSP request received for {operation}" - ))) - } -} diff --git a/crates/tools/src/orchestrator.rs b/crates/tools/src/orchestrator.rs deleted file mode 100644 index ace8faf..0000000 --- a/crates/tools/src/orchestrator.rs +++ /dev/null @@ -1,357 +0,0 @@ -#![allow(dead_code)] - -use std::sync::Arc; - -use tracing::{info, warn}; - -use devo_safety::legacy_permissions::{PermissionDecision, PermissionRequest, ResourceKind}; - -use crate::invocation::{ToolCallId, ToolInvocation, ToolName}; -use crate::{ToolContext, ToolOutput, ToolRegistry}; - -/// A pending tool call extracted from the model response. -#[derive(Debug, Clone)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub input: serde_json::Value, -} - -/// The result of executing a single tool call. -#[derive(Debug, Clone)] -pub struct ToolCallResult { - pub tool_use_id: String, - pub output: ToolOutput, -} - -/// Orchestrates the execution of tool calls. -#[allow(dead_code)] -pub struct ToolOrchestrator { - registry: Arc, -} - -#[allow(dead_code)] -impl ToolOrchestrator { - #[allow(dead_code)] - pub fn new(registry: Arc) -> Self { - Self { registry } - } - - /// Execute a batch of tool calls. - #[allow(dead_code)] - pub async fn execute_batch( - &self, - calls: &[ToolCall], - ctx: &ToolContext, - ) -> Vec { - let mut results = Vec::with_capacity(calls.len()); - - // Partition into concurrent (read-only) and sequential (mutating) - let (concurrent, sequential): (Vec<_>, Vec<_>) = calls - .iter() - .partition(|call| self.registry.supports_parallel(&call.name)); - - // Run concurrent tools in parallel - if !concurrent.is_empty() { - let futures: Vec<_> = concurrent - .iter() - .map(|call| self.execute_single(call, ctx)) - .collect(); - let concurrent_results = futures::future::join_all(futures).await; - results.extend(concurrent_results); - } - - // Run sequential tools one by one - for call in &sequential { - let result = self.execute_single(call, ctx).await; - results.push(result); - } - - results - } - - pub(crate) async fn execute_single( - &self, - call: &ToolCall, - ctx: &ToolContext, - ) -> ToolCallResult { - if !self.registry.is_read_only(&call.name) { - let request = PermissionRequest { - tool_name: call.name.clone(), - resource: ResourceKind::Custom(call.name.clone()), - description: format!("execute tool {}", call.name), - target: None, - }; - - match ctx.permissions.check(&request).await { - PermissionDecision::Allow => {} - PermissionDecision::Deny { reason } => { - return ToolCallResult { - tool_use_id: call.id.clone(), - output: ToolOutput::error(format!("permission denied: {}", reason)), - }; - } - PermissionDecision::Ask { message } => { - return ToolCallResult { - tool_use_id: call.id.clone(), - output: ToolOutput::error(format!( - "permission required — run with --permission interactive to approve: {}", - message - )), - }; - } - } - } - - info!(tool = %call.name, id = %call.id, "executing tool"); - - let handler = match self.registry.get(&call.name) { - Some(h) => h.clone(), - None => { - warn!(tool = %call.name, "tool not found"); - return ToolCallResult { - tool_use_id: call.id.clone(), - output: ToolOutput::error(format!("unknown tool: {}", call.name)), - }; - } - }; - - let invocation = ToolInvocation { - call_id: ToolCallId(call.id.clone()), - tool_name: ToolName(call.name.clone().into()), - session_id: ctx.session_id.clone(), - cwd: ctx.cwd.clone(), - input: call.input.clone(), - }; - - match handler.handle(invocation, None).await { - Ok(output) => { - let is_error = output.is_error(); - let content = output.to_content().into_string(); - ToolCallResult { - tool_use_id: call.id.clone(), - output: ToolOutput { - content, - is_error, - metadata: None, - }, - } - } - Err(e) => ToolCallResult { - tool_use_id: call.id.clone(), - output: ToolOutput::error(format!("tool execution failed: {}", e)), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::errors::ToolExecutionError; - use crate::events::ToolProgressSender; - use crate::handler_kind::ToolHandlerKind; - use crate::invocation::{FunctionToolOutput, ToolOutput}; - use crate::json_schema::JsonSchema; - use crate::registry::ToolRegistryBuilder; - use crate::tool_handler::ToolHandler; - use crate::tool_spec::{ToolExecutionMode, ToolOutputMode, ToolSpec}; - use async_trait::async_trait; - use devo_safety::legacy_permissions::{PermissionMode, RuleBasedPolicy}; - use std::sync::Arc; - - struct ReadOnlyHandler; - - #[async_trait] - impl ToolHandler for ReadOnlyHandler { - fn tool_kind(&self) -> ToolHandlerKind { - ToolHandlerKind::Read - } - async fn handle( - &self, - _invocation: ToolInvocation, - _progress: Option, - ) -> Result, ToolExecutionError> { - Ok(Box::new(FunctionToolOutput::success("read ok"))) - } - } - - struct WriteHandler; - - #[async_trait] - impl ToolHandler for WriteHandler { - fn tool_kind(&self) -> ToolHandlerKind { - ToolHandlerKind::Write - } - async fn handle( - &self, - _invocation: ToolInvocation, - _progress: Option, - ) -> Result, ToolExecutionError> { - Ok(Box::new(FunctionToolOutput::success("write ok"))) - } - } - - struct FailingHandler; - - #[async_trait] - impl ToolHandler for FailingHandler { - fn tool_kind(&self) -> ToolHandlerKind { - ToolHandlerKind::Invalid - } - async fn handle( - &self, - _invocation: ToolInvocation, - _progress: Option, - ) -> Result, ToolExecutionError> { - Err(ToolExecutionError::ExecutionFailed { - message: "something went wrong".into(), - }) - } - } - - fn register_tool( - builder: &mut ToolRegistryBuilder, - name: &str, - handler: Arc, - is_read_only: bool, - ) { - let mode = if is_read_only { - ToolExecutionMode::ReadOnly - } else { - ToolExecutionMode::Mutating - }; - builder.register_handler(name, handler); - builder.push_spec(ToolSpec { - name: name.to_string(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: mode, - capability_tags: vec![], - supports_parallel: is_read_only, - }); - } - - fn make_ctx(mode: PermissionMode) -> ToolContext { - ToolContext { - cwd: std::path::PathBuf::from("/tmp"), - permissions: Arc::new(RuleBasedPolicy::new(mode)), - session_id: "test-session".into(), - } - } - - #[tokio::test] - async fn unknown_tool_returns_error() { - let registry = Arc::new(ToolRegistry::new()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::AutoApprove); - - let call = ToolCall { - id: "c1".into(), - name: "nonexistent".into(), - input: serde_json::json!({}), - }; - let result = orch.execute_single(&call, &ctx).await; - assert!(result.output.is_error); - assert!(result.output.content.contains("unknown tool")); - } - - #[tokio::test] - async fn read_only_tool_skips_permission_check() { - let mut builder = ToolRegistryBuilder::new(); - register_tool(&mut builder, "read_tool", Arc::new(ReadOnlyHandler), true); - let registry = Arc::new(builder.build()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::Deny); - - let call = ToolCall { - id: "c1".into(), - name: "read_tool".into(), - input: serde_json::json!({}), - }; - let result = orch.execute_single(&call, &ctx).await; - assert!(!result.output.is_error); - assert_eq!(result.output.content, "read ok"); - } - - #[tokio::test] - async fn mutating_tool_denied_in_deny_mode() { - let mut builder = ToolRegistryBuilder::new(); - register_tool(&mut builder, "write_tool", Arc::new(WriteHandler), false); - let registry = Arc::new(builder.build()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::Deny); - - let call = ToolCall { - id: "c1".into(), - name: "write_tool".into(), - input: serde_json::json!({}), - }; - let result = orch.execute_single(&call, &ctx).await; - assert!(result.output.is_error); - assert!(result.output.content.contains("permission denied")); - } - - #[tokio::test] - async fn mutating_tool_allowed_in_auto_approve() { - let mut builder = ToolRegistryBuilder::new(); - register_tool(&mut builder, "write_tool", Arc::new(WriteHandler), false); - let registry = Arc::new(builder.build()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::AutoApprove); - - let call = ToolCall { - id: "c1".into(), - name: "write_tool".into(), - input: serde_json::json!({}), - }; - let result = orch.execute_single(&call, &ctx).await; - assert!(!result.output.is_error); - assert_eq!(result.output.content, "write ok"); - } - - #[tokio::test] - async fn failing_tool_wraps_error() { - let mut builder = ToolRegistryBuilder::new(); - register_tool(&mut builder, "fail_tool", Arc::new(FailingHandler), false); - let registry = Arc::new(builder.build()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::AutoApprove); - - let call = ToolCall { - id: "c1".into(), - name: "fail_tool".into(), - input: serde_json::json!({}), - }; - let result = orch.execute_single(&call, &ctx).await; - assert!(result.output.is_error); - assert!(result.output.content.contains("tool execution failed")); - } - - #[tokio::test] - async fn execute_batch_runs_all_tools() { - let mut builder = ToolRegistryBuilder::new(); - register_tool(&mut builder, "read_tool", Arc::new(ReadOnlyHandler), true); - register_tool(&mut builder, "write_tool", Arc::new(WriteHandler), false); - let registry = Arc::new(builder.build()); - let orch = ToolOrchestrator::new(registry); - let ctx = make_ctx(PermissionMode::AutoApprove); - - let calls = vec![ - ToolCall { - id: "c1".into(), - name: "read_tool".into(), - input: serde_json::json!({}), - }, - ToolCall { - id: "c2".into(), - name: "write_tool".into(), - input: serde_json::json!({}), - }, - ]; - let results = orch.execute_batch(&calls, &ctx).await; - assert_eq!(results.len(), 2); - assert!(results.iter().all(|r| !r.output.is_error)); - } -} diff --git a/crates/tools/src/plan.rs b/crates/tools/src/plan.rs deleted file mode 100644 index 4c1aa1b..0000000 --- a/crates/tools/src/plan.rs +++ /dev/null @@ -1,90 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -pub struct PlanTool; - -#[async_trait] -impl Tool for PlanTool { - fn name(&self) -> &str { - "update_plan" - } - - fn description(&self) -> &str { - "Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time." - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "explanation": { - "type": "string" - }, - "plan": { - "type": "array", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["pending", "in_progress", "completed"] - } - }, - "required": ["step", "status"], - "additionalProperties": false - } - } - }, - "required": ["plan"], - "additionalProperties": false - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let explanation = input - .get("explanation") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let plan = input - .get("plan") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| anyhow::anyhow!("missing 'plan' field"))?; - - let in_progress_count = plan - .iter() - .filter(|item| { - item.get("status").and_then(serde_json::Value::as_str) == Some("in_progress") - }) - .count(); - if in_progress_count > 1 { - return Ok(ToolOutput::error( - "At most one step can be in_progress at a time.".to_string(), - )); - } - - let plan_text = serde_json::to_string_pretty(plan)?; - let content = if explanation.trim().is_empty() { - plan_text.clone() - } else { - format!("{explanation}\n\n{plan_text}") - }; - - Ok(ToolOutput { - content, - is_error: false, - metadata: Some(json!({ - "explanation": explanation, - "plan": plan, - })), - }) - } -} diff --git a/crates/tools/src/question.rs b/crates/tools/src/question.rs deleted file mode 100644 index 43452ff..0000000 --- a/crates/tools/src/question.rs +++ /dev/null @@ -1,38 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -pub struct QuestionTool; - -#[async_trait] -impl Tool for QuestionTool { - fn name(&self) -> &str { - "question" - } - - fn description(&self) -> &str { - "Ask the user a clarifying question." - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "question": {"type": "string"} - }, - "required": ["question"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let question = input["question"].as_str().unwrap_or(""); - Ok(ToolOutput::success(format!( - "Question for user: {question}" - ))) - } -} diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index cfd94f5..28b956f 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -1,96 +1,12 @@ +use serde_json::json; use std::fs::File; use std::io::BufRead; use std::io::BufReader; use std::io::Read; use std::path::Path; -use std::path::PathBuf; - -use async_trait::async_trait; -use serde_json::json; -use crate::Tool; -use crate::ToolContext; use crate::ToolOutput; -const DESCRIPTION: &str = include_str!("read.txt"); - -pub struct ReadTool; - -#[async_trait] -impl Tool for ReadTool { - fn name(&self) -> &str { - "read" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "filePath": { - "type": "string", - "description": "The absolute path to the file or directory to read" - }, - "offset": { - "type": "integer", - "description": "The line number to start reading from (1-indexed, default 1)" - }, - "limit": { - "type": "integer", - "description": "The maximum number of lines to read (no limit by default)" - } - }, - "required": ["filePath"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let mut filepath = input["filePath"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("missing 'filePath' field"))? - .to_string(); - let offset = input["offset"].as_u64().map(|value| value as usize); - let limit = input["limit"].as_u64().map(|value| value as usize); - - if let Some(offset) = offset - && offset < 1 - { - return Ok(ToolOutput::error( - "offset must be greater than or equal to 1", - )); - } - - if !Path::new(&filepath).is_absolute() { - filepath = ctx.cwd.join(&filepath).to_string_lossy().to_string(); - } - - let path = PathBuf::from(&filepath); - if !path.exists() { - return Ok(ToolOutput::error(missing_file_message(&filepath))); - } - - if path.is_dir() { - return read_directory(&path, limit.unwrap_or(usize::MAX), offset.unwrap_or(1)); - } - - if is_binary_file(&path)? { - return Ok(ToolOutput::error(format!( - "Cannot read binary file: {}", - path.display() - ))); - } - - read_file(&path, limit.unwrap_or(usize::MAX), offset.unwrap_or(1)) - } -} - pub(crate) fn read_directory( path: &Path, limit: usize, diff --git a/crates/tools/src/registry.rs b/crates/tools/src/registry.rs index ef5a902..f0e2b14 100644 --- a/crates/tools/src/registry.rs +++ b/crates/tools/src/registry.rs @@ -7,12 +7,14 @@ use crate::errors::ToolDispatchError; use crate::invocation::{ToolInvocation, ToolOutput}; use crate::tool_handler::ToolHandler; use crate::tool_spec::{ToolExecutionMode, ToolSpec}; +use crate::unified_exec::store::ProcessStore; #[derive(Clone)] pub struct ToolRegistry { pub(crate) handlers: HashMap>, pub(crate) specs: Vec, pub(crate) spec_index: HashMap, + pub(crate) unified_exec_store: Option>, } impl ToolRegistry { @@ -21,6 +23,7 @@ impl ToolRegistry { handlers: HashMap::new(), specs: Vec::new(), spec_index: HashMap::new(), + unified_exec_store: None, } } @@ -64,7 +67,11 @@ impl ToolRegistry { .map(|spec| ToolDefinition { name: spec.name.clone(), description: spec.description.clone(), - input_schema: spec.input_schema.to_json_value(), + input_schema: unified_exec_input_schema( + &spec.name, + spec.input_schema.to_json_value(), + ), + output_schema: unified_exec_output_schema(&spec.name), }) .collect() } @@ -80,6 +87,128 @@ impl ToolRegistry { pub fn len(&self) -> usize { self.handlers.len() } + + pub async fn terminate_unified_exec_processes(&self) { + if let Some(store) = &self.unified_exec_store { + store.terminate_all().await; + } + } +} + +fn unified_exec_input_schema(tool_name: &str, mut schema: serde_json::Value) -> serde_json::Value { + if tool_name != "exec_command" { + return schema; + } + + let Some(properties) = schema + .get_mut("properties") + .and_then(serde_json::Value::as_object_mut) + else { + return schema; + }; + + properties.insert( + "sandbox_permissions".to_string(), + serde_json::json!({ + "type": "string", + "description": "Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".", + "enum": ["use_default", "require_escalated", "with_additional_permissions"] + }), + ); + properties.insert( + "additional_permissions".to_string(), + additional_permissions_schema(), + ); + properties.insert( + "justification".to_string(), + serde_json::json!({ + "type": "string", + "description": "Only set if sandbox_permissions is \"require_escalated\".\nRequest approval from the user to run this command outside the sandbox.\nPhrased as a simple question that summarizes the purpose of the\ncommand as it relates to the task at hand - e.g. 'Do you want to\nfetch and pull the latest version of this git branch?'" + }), + ); + properties.insert( + "prefix_rule".to_string(), + serde_json::json!({ + "type": "array", + "description": "Only specify when sandbox_permissions is `require_escalated`.\nSuggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.\nShould be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"].", + "items": { "type": "string" } + }), + ); + + schema +} + +fn additional_permissions_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "network": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Set to true to request network access." + } + }, + "additionalProperties": false + }, + "file_system": { + "type": "object", + "properties": { + "read": { + "type": "array", + "description": "Absolute paths to grant read access to.", + "items": { "type": "string" } + }, + "write": { + "type": "array", + "description": "Absolute paths to grant write access to.", + "items": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn unified_exec_output_schema(tool_name: &str) -> Option { + if tool_name != "exec_command" && tool_name != "write_stdin" { + return None; + } + + Some(serde_json::json!({ + "type": "object", + "properties": { + "chunk_id": { + "type": "string", + "description": "Chunk identifier included when the response reports one." + }, + "wall_time_seconds": { + "type": "number", + "description": "Elapsed wall time spent waiting for output in seconds." + }, + "exit_code": { + "type": "number", + "description": "Process exit code when the command finished during this call." + }, + "session_id": { + "type": "number", + "description": "Session identifier to pass to write_stdin when the process is still running." + }, + "original_token_count": { + "type": "number", + "description": "Approximate token count before output truncation." + }, + "output": { + "type": "string", + "description": "Command output text, possibly truncated." + } + }, + "required": ["wall_time_seconds", "output"], + "additionalProperties": false + })) } impl Default for ToolRegistry { @@ -92,6 +221,7 @@ pub struct ToolRegistryBuilder { handlers: HashMap>, specs: Vec, spec_index: HashMap, + unified_exec_store: Option>, } impl ToolRegistryBuilder { @@ -100,6 +230,7 @@ impl ToolRegistryBuilder { handlers: HashMap::new(), specs: Vec::new(), spec_index: HashMap::new(), + unified_exec_store: None, } } @@ -113,11 +244,16 @@ impl ToolRegistryBuilder { self.handlers.insert(name.to_string(), handler); } + pub fn set_unified_exec_store(&mut self, store: Arc) { + self.unified_exec_store = Some(store); + } + pub fn build(self) -> ToolRegistry { ToolRegistry { handlers: self.handlers, specs: self.specs, spec_index: self.spec_index, + unified_exec_store: self.unified_exec_store, } } } @@ -194,6 +330,62 @@ mod tests { assert_eq!(defs[0].description, "test"); } + #[test] + fn registry_adds_output_schema_for_unified_exec_tools() { + let mut builder = ToolRegistryBuilder::new(); + builder.push_spec(ToolSpec { + name: "exec_command".into(), + description: "exec".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Mixed, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: true, + }); + + let registry = builder.build(); + let defs = registry.tool_definitions(); + + assert!(defs[0].output_schema.is_some()); + } + + #[test] + fn registry_adds_permission_fields_for_exec_command() { + let mut builder = ToolRegistryBuilder::new(); + builder.push_spec(ToolSpec { + name: "exec_command".into(), + description: "exec".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Mixed, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: true, + }); + + let registry = builder.build(); + let defs = registry.tool_definitions(); + let properties = defs[0] + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("object schema should have properties"); + + assert!(properties.contains_key("sandbox_permissions")); + assert!(properties.contains_key("additional_permissions")); + assert!(properties.contains_key("justification")); + assert!(properties.contains_key("prefix_rule")); + assert_eq!( + properties["additional_permissions"]["properties"]["network"]["properties"]["enabled"] + ["type"], + "boolean" + ); + assert_eq!( + properties["additional_permissions"]["properties"]["file_system"]["properties"]["read"] + ["items"]["type"], + "string" + ); + } + #[tokio::test] async fn registry_dispatch_unknown_tool() { let builder = ToolRegistryBuilder::new(); diff --git a/crates/tools/src/router.rs b/crates/tools/src/router.rs index b81e95e..df5dac3 100644 --- a/crates/tools/src/router.rs +++ b/crates/tools/src/router.rs @@ -1,16 +1,20 @@ +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; +use devo_safety::ResourceKind; use futures::future::join_all; use tokio::sync::RwLock; use tracing::{info, warn}; use crate::invocation::{ToolCallId, ToolContent, ToolInvocation, ToolName}; use crate::registry::ToolRegistry; +use crate::tool_spec::ToolCapabilityTag; type ProgressCallback = dyn Fn(&str, &str) + Send + Sync; type ProgressCallbackArc = Arc; type PermissionFuture = futures::future::BoxFuture<'static, Result<(), String>>; -type PermissionCheckFn = dyn Fn(&str) -> PermissionFuture + Send + Sync; +type PermissionCheckFn = dyn Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync; #[derive(Debug, Clone)] pub struct ToolCall { @@ -48,6 +52,7 @@ pub struct ToolRuntime { registry: Arc, permission: PermissionChecker, gate: RwLock<()>, + context: ToolRuntimeContext, } impl ToolRuntime { @@ -56,6 +61,20 @@ impl ToolRuntime { registry, permission, gate: RwLock::new(()), + context: ToolRuntimeContext::default(), + } + } + + pub fn new_with_context( + registry: Arc, + permission: PermissionChecker, + context: ToolRuntimeContext, + ) -> Self { + ToolRuntime { + registry, + permission, + gate: RwLock::new(()), + context, } } @@ -64,6 +83,7 @@ impl ToolRuntime { registry, permission: PermissionChecker::always_allow(), gate: RwLock::new(()), + context: ToolRuntimeContext::default(), } } @@ -126,13 +146,13 @@ impl ToolRuntime { } }; - if !self.registry.is_read_only(&call.name) { - match self.permission.check(&call.name).await { + if let Some(request) = self.permission_request_for_call(call) { + match self.permission.check(request).await { Ok(()) => {} Err(reason) => { return ToolCallResult::error( &call.id, - &format!("permission denied: {}", reason), + &format!("permission denied: {reason}"), ); } } @@ -143,8 +163,8 @@ impl ToolRuntime { let invocation = ToolInvocation { call_id: ToolCallId(call.id.clone()), tool_name: ToolName(call.name.clone().into()), - session_id: String::new(), - cwd: std::path::PathBuf::new(), + session_id: self.context.session_id.clone(), + cwd: self.context.cwd.clone(), input: call.input.clone(), }; @@ -173,6 +193,44 @@ impl ToolRuntime { Err(e) => ToolCallResult::error(&call.id, &e.to_string()), } } + + fn permission_request_for_call(&self, call: &ToolCall) -> Option { + let spec = self.registry.spec(&call.name)?; + let needs_permission = spec.execution_mode == crate::tool_spec::ToolExecutionMode::Mutating + || spec + .capability_tags + .iter() + .any(|tag| matches!(tag, ToolCapabilityTag::NetworkAccess)); + if !needs_permission { + return None; + } + + let resource = resource_kind_for_tool(&call.name, &spec.capability_tags); + let path = path_for_tool_input(&call.name, &call.input, &self.context.cwd); + let host = host_for_tool_input(&call.name, &call.input); + let target = target_for_tool_input(&call.name, &call.input); + let command_prefix = command_prefix_for_tool_input(&call.name, &call.input); + Some(ToolPermissionRequest { + tool_call_id: call.id.clone(), + tool_name: call.name.clone(), + input: call.input.clone(), + cwd: self.context.cwd.clone(), + session_id: self.context.session_id.clone(), + turn_id: self.context.turn_id.clone(), + resource, + action_summary: crate::tool_summary::tool_summary( + &call.name, + &call.input, + &self.context.cwd, + ), + justification: justification_for_tool_input(&call.input), + path, + host, + target, + command_prefix, + requests_escalation: requests_explicit_escalation(&call.input), + }) + } } #[derive(Clone)] @@ -183,7 +241,7 @@ pub struct PermissionChecker { impl PermissionChecker { pub fn new(check: F) -> Self where - F: Fn(&str) -> PermissionFuture + Send + Sync + 'static, + F: Fn(ToolPermissionRequest) -> PermissionFuture + Send + Sync + 'static, { PermissionChecker { inner: Arc::new(check), @@ -194,11 +252,231 @@ impl PermissionChecker { PermissionChecker::new(|_| Box::pin(async { Ok(()) })) } - pub async fn check(&self, tool_name: &str) -> Result<(), String> { - (self.inner)(tool_name).await + pub async fn check(&self, request: ToolPermissionRequest) -> Result<(), String> { + (self.inner)(request).await } } +#[derive(Debug, Clone, Default)] +pub struct ToolRuntimeContext { + pub session_id: String, + pub turn_id: Option, + pub cwd: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct ToolPermissionRequest { + pub tool_call_id: String, + pub tool_name: String, + pub input: serde_json::Value, + pub cwd: PathBuf, + pub session_id: String, + pub turn_id: Option, + pub resource: ResourceKind, + pub action_summary: String, + pub justification: Option, + pub path: Option, + pub host: Option, + pub target: Option, + pub command_prefix: Option>, + pub requests_escalation: bool, +} + +fn resource_kind_for_tool(tool_name: &str, tags: &[ToolCapabilityTag]) -> ResourceKind { + if tags + .iter() + .any(|tag| matches!(tag, ToolCapabilityTag::NetworkAccess)) + { + return ResourceKind::Network; + } + if tags + .iter() + .any(|tag| matches!(tag, ToolCapabilityTag::ExecuteProcess)) + { + return ResourceKind::ShellExec; + } + if tags + .iter() + .any(|tag| matches!(tag, ToolCapabilityTag::WriteFiles)) + { + return ResourceKind::FileWrite; + } + if tags.iter().any(|tag| { + matches!( + tag, + ToolCapabilityTag::ReadFiles | ToolCapabilityTag::SearchWorkspace + ) + }) { + return ResourceKind::FileRead; + } + ResourceKind::Custom(tool_name.to_string()) +} + +fn path_for_tool_input(tool_name: &str, input: &serde_json::Value, cwd: &Path) -> Option { + let raw = match tool_name { + "read" | "write" | "lsp" => input + .get("filePath") + .and_then(serde_json::Value::as_str) + .or_else(|| input.get("path").and_then(serde_json::Value::as_str)), + "grep" | "glob" => input.get("path").and_then(serde_json::Value::as_str), + _ => None, + }?; + let path = PathBuf::from(raw); + Some(if path.is_absolute() { + path + } else { + cwd.join(path) + }) +} + +fn host_for_tool_input(tool_name: &str, input: &serde_json::Value) -> Option { + match tool_name { + "webfetch" => input + .get("url") + .and_then(serde_json::Value::as_str) + .and_then(host_from_url), + "websearch" => input + .get("query") + .and_then(serde_json::Value::as_str) + .map(|_| "websearch".to_string()), + _ => None, + } +} + +fn host_from_url(url: &str) -> Option { + let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); + after_scheme + .split('/') + .next() + .and_then(|host| (!host.is_empty()).then(|| host.to_string())) +} + +fn target_for_tool_input(tool_name: &str, input: &serde_json::Value) -> Option { + match tool_name { + "bash" | "shell_command" => input + .get("command") + .or_else(|| input.get("cmd")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + "exec_command" => input + .get("cmd") + .or_else(|| input.get("command")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + "webfetch" => input + .get("url") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + "websearch" => input + .get("query") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + _ => None, + } +} + +fn command_prefix_for_tool_input( + tool_name: &str, + input: &serde_json::Value, +) -> Option> { + if tool_name == "exec_command" + && let Some(prefix_rule) = input.get("prefix_rule").and_then(prefix_rule_from_value) + { + return Some(prefix_rule); + } + + let command = match tool_name { + "bash" | "shell_command" => input + .get("command") + .or_else(|| input.get("cmd")) + .and_then(serde_json::Value::as_str), + "exec_command" => input + .get("cmd") + .or_else(|| input.get("command")) + .and_then(serde_json::Value::as_str), + _ => None, + }?; + command_prefix(command) +} + +fn prefix_rule_from_value(value: &serde_json::Value) -> Option> { + let prefix = value + .as_array()? + .iter() + .map(serde_json::Value::as_str) + .collect::>>()?; + (!prefix.is_empty()).then(|| prefix.into_iter().map(str::to_string).collect()) +} + +fn requests_explicit_escalation(input: &serde_json::Value) -> bool { + matches!( + input + .get("sandbox_permissions") + .and_then(serde_json::Value::as_str), + Some("require_escalated" | "with_additional_permissions") + ) || input.get("additional_permissions").is_some() +} + +fn command_prefix(command: &str) -> Option> { + let argv = shlex::split(command)?; + if argv + .iter() + .any(|token| shell_token_requires_user_scope(command, token)) + || argv + .first() + .is_some_and(|token| looks_like_env_assignment(token)) + { + return None; + } + prefix_from_argv(&argv) +} + +fn shell_token_requires_user_scope(command: &str, token: &str) -> bool { + token.contains(['|', ';', '>', '<', '*', '?', '$', '(', ')']) + || token.contains("$(") + || command.contains("&&") + || command.contains("||") + || command.contains("$(") + || command.contains('`') +} + +fn looks_like_env_assignment(token: &str) -> bool { + let Some((name, value)) = token.split_once('=') else { + return false; + }; + !name.is_empty() + && !value.is_empty() + && name + .chars() + .all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) + && name + .chars() + .next() + .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()) +} + +fn prefix_from_argv(argv: &[String]) -> Option> { + let executable = argv.first()?.clone(); + let second = argv + .iter() + .skip(1) + .find(|token| !token.starts_with('-')) + .cloned(); + Some( + second + .map(|token| vec![executable.clone(), token]) + .unwrap_or_else(|| vec![executable]), + ) +} + +fn justification_for_tool_input(input: &serde_json::Value) -> Option { + input + .get("justification") + .or_else(|| input.get("description")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +489,7 @@ mod tests { use crate::tool_handler::ToolHandler; use crate::tool_spec::{ToolExecutionMode, ToolOutputMode, ToolSpec}; use async_trait::async_trait; + use pretty_assertions::assert_eq; struct ReadOnlyTool; @@ -265,7 +544,7 @@ mod tests { input_schema: JsonSchema::object(Default::default(), None, None), output_mode: ToolOutputMode::Text, execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], + capability_tags: vec![ToolCapabilityTag::WriteFiles], supports_parallel: false, }); Arc::new(builder.build()) @@ -322,13 +601,18 @@ mod tests { #[tokio::test] async fn permission_checker_allow() { let checker = PermissionChecker::always_allow(); - assert!(checker.check("any_tool").await.is_ok()); + assert!( + checker + .check(test_permission_request("any_tool")) + .await + .is_ok() + ); } #[tokio::test] async fn permission_checker_deny() { - let checker = PermissionChecker::new(|name| { - let n = name.to_string(); + let checker = PermissionChecker::new(|request| { + let n = request.tool_name; Box::pin(async move { if n == "blocked" { Err("blocked".into()) @@ -337,15 +621,25 @@ mod tests { } }) }); - assert!(checker.check("allowed").await.is_ok()); - assert!(checker.check("blocked").await.is_err()); + assert!( + checker + .check(test_permission_request("allowed")) + .await + .is_ok() + ); + assert!( + checker + .check(test_permission_request("blocked")) + .await + .is_err() + ); } #[tokio::test] async fn runtime_denies_mutating_with_deny_checker() { let registry = make_registry(); - let checker = PermissionChecker::new(|name| { - let n = name.to_string(); + let checker = PermissionChecker::new(|request| { + let n = request.tool_name; Box::pin(async move { Err(format!("{n} denied")) }) }); let runtime = ToolRuntime::new(registry, checker); @@ -377,6 +671,135 @@ mod tests { ); } + #[tokio::test] + async fn mutating_tool_permission_request_carries_context_and_summary() { + let registry = make_registry(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = std::sync::Mutex::new(Some(tx)); + let checker = PermissionChecker::new(move |request| { + tx.lock() + .expect("lock sender") + .take() + .expect("send once") + .send(request) + .expect("receiver still alive"); + Box::pin(async { Ok(()) }) + }); + let runtime = ToolRuntime::new_with_context( + registry, + checker, + ToolRuntimeContext { + session_id: "session-1".into(), + turn_id: Some("turn-1".into()), + cwd: PathBuf::from("C:/workspace"), + }, + ); + let call = ToolCall { + id: "call-1".into(), + name: "write_tool".into(), + input: serde_json::json!({ "filePath": "src/main.rs" }), + }; + + let result = runtime.execute_single(&call, &None).await; + let request = rx.await.expect("permission request"); + + assert!(!result.is_error); + assert_eq!(request.tool_call_id, "call-1"); + assert_eq!(request.tool_name, "write_tool"); + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.turn_id, Some("turn-1".into())); + assert_eq!(request.resource, devo_safety::ResourceKind::FileWrite); + } + + #[test] + fn path_for_tool_input_resolves_relative_paths_against_cwd() { + let path = path_for_tool_input( + "write", + &serde_json::json!({ "filePath": "src/lib.rs" }), + Path::new("C:/workspace"), + ); + + assert_eq!(path, Some(PathBuf::from("C:/workspace").join("src/lib.rs"))); + } + + #[test] + fn host_from_url_ignores_scheme_and_path() { + assert_eq!( + host_from_url("https://example.com/docs/index.html"), + Some("example.com".into()) + ); + } + + #[test] + fn command_prefix_uses_first_command_tokens() { + assert_eq!( + command_prefix("git add -A"), + Some(vec!["git".to_string(), "add".to_string()]) + ); + assert_eq!( + command_prefix("'cargo' test --all"), + Some(vec!["cargo".to_string(), "test".to_string()]) + ); + } + + #[test] + fn command_prefix_rejects_complex_shell_features() { + assert_eq!(command_prefix("git add -A | tee out.txt"), None); + assert_eq!(command_prefix("npm test > output.txt"), None); + assert_eq!(command_prefix("echo $(pwd)"), None); + assert_eq!(command_prefix("echo $HOME"), None); + assert_eq!(command_prefix("FOO=bar cargo test"), None); + assert_eq!(command_prefix("(pwd)"), None); + assert_eq!(command_prefix("rg *.rs"), None); + assert_eq!(command_prefix("cargo fmt && cargo test"), None); + } + + #[test] + fn exec_command_prefix_rule_overrides_derived_prefix() { + assert_eq!( + command_prefix_for_tool_input( + "exec_command", + &serde_json::json!({ + "cmd": "git add -A", + "prefix_rule": ["cargo", "test"] + }) + ), + Some(vec!["cargo".to_string(), "test".to_string()]) + ); + } + + #[test] + fn explicit_sandbox_permissions_request_escalation() { + assert!(requests_explicit_escalation(&serde_json::json!({ + "sandbox_permissions": "require_escalated" + }))); + assert!(requests_explicit_escalation(&serde_json::json!({ + "additional_permissions": {"network": true} + }))); + assert!(!requests_explicit_escalation(&serde_json::json!({ + "sandbox_permissions": "use_default" + }))); + } + + fn test_permission_request(tool_name: &str) -> ToolPermissionRequest { + ToolPermissionRequest { + tool_call_id: "call".into(), + tool_name: tool_name.into(), + input: serde_json::json!({}), + cwd: std::path::PathBuf::new(), + session_id: "session".into(), + turn_id: Some("turn".into()), + resource: devo_safety::ResourceKind::Custom(tool_name.into()), + action_summary: tool_name.into(), + justification: None, + path: None, + host: None, + target: None, + command_prefix: None, + requests_escalation: false, + } + } + #[tokio::test] async fn runtime_concurrent_then_sequential() { // Two parallel tools followed by a sequential tool should still work diff --git a/crates/tools/src/shell_exec.rs b/crates/tools/src/shell_exec.rs index ddb0c30..e070362 100644 --- a/crates/tools/src/shell_exec.rs +++ b/crates/tools/src/shell_exec.rs @@ -244,6 +244,7 @@ fn resolve_shell(shell: Option<&str>, login: bool) -> ShellSpec { } } +#[cfg(test)] pub(crate) fn platform_shell_program(login: bool) -> &'static str { platform_shell(login).program } diff --git a/crates/tools/src/skill.rs b/crates/tools/src/skill.rs deleted file mode 100644 index 6d6a2a2..0000000 --- a/crates/tools/src/skill.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("skill.txt"); - -pub struct SkillTool; - -#[async_trait] -impl Tool for SkillTool { - fn name(&self) -> &str { - "skill" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { "name": {"type": "string"} }, - "required": ["name"] - }) - } - - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let name = input["name"].as_str().unwrap_or(""); - let found = find_skill(&ctx.cwd, name) - .ok_or_else(|| anyhow::anyhow!("Skill \"{name}\" not found"))?; - let content = fs::read_to_string(&found)?; - let dir = found.parent().unwrap_or(Path::new("")).to_path_buf(); - let files = sample_files(&dir); - Ok(ToolOutput::success(format!( - "\n# Skill: {name}\n\n{content}\n\nBase directory for this skill: {}\nRelative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\nNote: file list is sampled.\n\n\n{}\n\n", - dir.display(), - files.join("\n") - ))) - } -} - -fn find_skill(root: &Path, name: &str) -> Option { - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - if let Ok(read) = fs::read_dir(&dir) { - for entry in read.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.file_name().and_then(|x| x.to_str()) == Some("SKILL.md") - && path.parent()?.file_name().and_then(|x| x.to_str()) == Some(name) - { - return Some(path); - } - } - } - } - None -} - -fn sample_files(dir: &Path) -> Vec { - let mut files = Vec::new(); - if let Ok(read) = fs::read_dir(dir) { - for entry in read.flatten() { - let path = entry.path(); - if path.file_name().and_then(|x| x.to_str()) == Some("SKILL.md") { - continue; - } - files.push(format!("{}", path.display())); - if files.len() >= 10 { - break; - } - } - } - files -} diff --git a/crates/tools/src/task.rs b/crates/tools/src/task.rs deleted file mode 100644 index 431322e..0000000 --- a/crates/tools/src/task.rs +++ /dev/null @@ -1,50 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; -use uuid::Uuid; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("task.txt"); - -pub struct TaskTool; - -#[async_trait] -impl Tool for TaskTool { - fn name(&self) -> &str { - "task" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "description": {"type": "string"}, - "prompt": {"type": "string"}, - "subagent_type": {"type": "string"}, - "task_id": {"type": "string"}, - "command": {"type": "string"} - }, - "required": ["description", "prompt", "subagent_type"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let description = input["description"].as_str().unwrap_or("task"); - let task_id = input["task_id"] - .as_str() - .map(ToOwned::to_owned) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let prompt = input["prompt"].as_str().unwrap_or(""); - Ok(ToolOutput::success(format!( - "task_id: {task_id} (for resuming to continue this task if needed)\n\n\nTask requested: {description}\n{prompt}\n" - ))) - } -} diff --git a/crates/tools/src/todo.rs b/crates/tools/src/todo.rs deleted file mode 100644 index 8422b33..0000000 --- a/crates/tools/src/todo.rs +++ /dev/null @@ -1,38 +0,0 @@ -use async_trait::async_trait; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("todowrite.txt"); - -pub struct TodoWriteTool; - -#[async_trait] -impl Tool for TodoWriteTool { - fn name(&self) -> &str { - "todowrite" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "todos": {"type": "array"} - }, - "required": ["todos"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let todos = input["todos"].as_array().cloned().unwrap_or_default(); - Ok(ToolOutput::success(serde_json::to_string_pretty(&todos)?)) - } -} diff --git a/crates/tools/src/tool.rs b/crates/tools/src/tool.rs index 9fc8f15..53f1452 100644 --- a/crates/tools/src/tool.rs +++ b/crates/tools/src/tool.rs @@ -1,8 +1,5 @@ -use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use crate::ToolContext; - /// The output returned by a tool after execution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolOutput { @@ -30,57 +27,6 @@ impl ToolOutput { } } -/// Incremental progress events a tool can emit during execution. -/// -/// These are surfaced to the UI layer (CLI spinner, TUI progress bar) without -/// blocking the tool's final result. Mirrors the `ToolProgressData` union in -/// Claude Code's `types/tools.ts`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ToolProgressEvent { - /// Generic text status update (e.g. "compiling…", "fetching page 2/5"). - Status { message: String }, - /// Byte-level progress for long I/O operations. - ByteProgress { done: u64, total: Option }, - /// A sub-command was spawned (tool_name, command string). - SubCommand { tool: String, command: String }, -} - -/// The core trait every tool must implement. -/// -/// Inspired by Claude Code's Tool.ts but redesigned for Rust: -/// - Tools receive only what they need via [`ToolContext`], not a giant context object. -/// - Schema is provided as JSON Schema for model compatibility. -/// - Read-only vs mutating is declared statically so the permission layer can optimize. -#[async_trait] -pub trait Tool: Send + Sync { - /// Unique tool name visible to the model. - fn name(&self) -> &str; - - /// Human-readable description used in the model's tool prompt. - fn description(&self) -> &str; - - /// JSON Schema describing the expected input. - fn input_schema(&self) -> serde_json::Value; - - /// Execute the tool with validated input. - async fn execute( - &self, - ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result; - - /// Whether this tool only reads state (no side effects). - fn is_read_only(&self) -> bool { - false - } - - /// Whether this tool can be run concurrently with others. - fn supports_concurrency(&self) -> bool { - self.is_read_only() - } -} - #[cfg(test)] mod tests { use super::*; @@ -114,21 +60,4 @@ mod tests { assert!(!deserialized.is_error); assert!(deserialized.metadata.is_some()); } - - #[test] - fn tool_progress_event_serde() { - let event = ToolProgressEvent::Status { - message: "compiling...".into(), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("status")); - assert!(json.contains("compiling...")); - - let event = ToolProgressEvent::ByteProgress { - done: 100, - total: Some(200), - }; - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("byte_progress")); - } } diff --git a/crates/tools/src/tool_summary.rs b/crates/tools/src/tool_summary.rs index f8429ba..aab9e99 100644 --- a/crates/tools/src/tool_summary.rs +++ b/crates/tools/src/tool_summary.rs @@ -1,4 +1,8 @@ -use std::path::{Path, PathBuf}; +// TODO: Current tool sumary is a function with match, we need to refactor the summary as tool trait +// and implement the summary as a method of a tool. + +use std::path::Path; +use std::path::PathBuf; fn make_relative(cwd: &Path, path: &str) -> String { let p = PathBuf::from(path); diff --git a/crates/tools/src/unified_exec/buffer.rs b/crates/tools/src/unified_exec/buffer.rs index 4972493..942e8f5 100644 --- a/crates/tools/src/unified_exec/buffer.rs +++ b/crates/tools/src/unified_exec/buffer.rs @@ -68,10 +68,6 @@ impl HeadTailBuffer { let head_str = String::from_utf8_lossy(&self.head); result.push_str(&head_str); - if self.dropped { - result.push_str("\n\n... [output truncated]\n\n"); - } - let tail_str = String::from_utf8_lossy(&self.tail); result.push_str(&tail_str); @@ -80,15 +76,21 @@ impl HeadTailBuffer { /// Collect raw bytes (for when callers need `Vec` directly) pub fn collect_bytes(&self) -> Vec { - let mut result = Vec::with_capacity(self.head.len() + self.tail.len() + 100); + let mut result = Vec::with_capacity(self.head.len() + self.tail.len()); result.extend_from_slice(&self.head); - if self.dropped { - result.extend_from_slice(b"\n\n... [output truncated]\n\n"); - } result.extend_from_slice(&self.tail); result } + pub fn drain_collect_bytes(&mut self) -> Vec { + let result = self.collect_bytes(); + self.head.clear(); + self.tail.clear(); + self.total = 0; + self.dropped = false; + result + } + pub fn total(&self) -> usize { self.total } @@ -206,6 +208,17 @@ mod tests { assert_eq!(&buf.collect_bytes(), b"hello"); } + #[test] + fn buffer_drain_collect_bytes_clears_buffer() { + let mut buf = HeadTailBuffer::new(); + buf.push(b"hello"); + + assert_eq!(&buf.drain_collect_bytes(), b"hello"); + assert_eq!(&buf.collect_bytes(), b""); + assert_eq!(buf.total(), 0); + assert!(!buf.truncated()); + } + #[test] fn buffer_truncation_preserves_tail() { let mut buf = HeadTailBuffer::new(); @@ -217,9 +230,9 @@ mod tests { assert!(buf.truncated()); let result = buf.collect(); - // Should have head and tail, with truncation marker in between + // Should have head and tail without inserting a second truncation marker. assert!(result.starts_with("AAAAAAAAAA")); - assert!(result.contains("... [output truncated]")); + assert_eq!(result.len(), 40); } #[test] diff --git a/crates/tools/src/unified_exec/mod.rs b/crates/tools/src/unified_exec/mod.rs index bd92927..96c25a8 100644 --- a/crates/tools/src/unified_exec/mod.rs +++ b/crates/tools/src/unified_exec/mod.rs @@ -1,12 +1,31 @@ pub mod buffer; pub mod process; pub mod store; +#[cfg(windows)] +pub mod windows_pty; pub const MAX_PROCESSES: usize = 64; pub const WARNING_PROCESSES: usize = 60; pub const DEFAULT_YIELD_MS: u64 = 10_000; pub const DEFAULT_POLL_YIELD_MS: u64 = 250; -pub const MAX_OUTPUT_TOKENS: usize = 16_000; +pub const MIN_YIELD_TIME_MS: u64 = 250; +pub const MIN_EMPTY_YIELD_TIME_MS: u64 = 5_000; +pub const MAX_YIELD_TIME_MS: u64 = 30_000; +pub const MAX_WRITE_STDIN_YIELD_MS: u64 = 300_000; +pub const MAX_OUTPUT_TOKENS: usize = 10_000; + +pub fn clamp_exec_yield_time(yield_time_ms: u64) -> u64 { + yield_time_ms.clamp(MIN_YIELD_TIME_MS, MAX_YIELD_TIME_MS) +} + +pub fn clamp_write_stdin_yield_time(yield_time_ms: u64, chars: &str) -> u64 { + let time_ms = yield_time_ms.max(MIN_YIELD_TIME_MS); + if chars.is_empty() { + time_ms.clamp(MIN_EMPTY_YIELD_TIME_MS, MAX_WRITE_STDIN_YIELD_MS) + } else { + time_ms.min(MAX_YIELD_TIME_MS) + } +} pub struct ExecCommandArgs { pub cmd: String, @@ -30,4 +49,5 @@ pub struct ProcessOutput { pub exit_code: Option, pub wall_time_secs: f64, pub truncated: bool, + pub original_token_count: usize, } diff --git a/crates/tools/src/unified_exec/process.rs b/crates/tools/src/unified_exec/process.rs index 5bc1465..f72b48f 100644 --- a/crates/tools/src/unified_exec/process.rs +++ b/crates/tools/src/unified_exec/process.rs @@ -1,11 +1,14 @@ use std::io::Write; use std::path::Path; +use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use portable_pty::{CommandBuilder, PtySize}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::sync::Mutex as AsyncMutex; use tokio::sync::broadcast; use tokio::time::{Duration, sleep}; @@ -15,46 +18,132 @@ use super::buffer::HeadTailBuffer; const PTY_READ_BUF: usize = 4096; const PTY_ROWS: u16 = 24; const PTY_COLS: u16 = 120; - +const PTY_TRAILING_OUTPUT_GRACE_MS: u64 = 150; +const POWERSHELL_UTF8_OUTPUT_PREFIX: &str = + "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n"; +const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ + ("NO_COLOR", "1"), + ("TERM", "dumb"), + ("LANG", "C.UTF-8"), + ("LC_CTYPE", "C.UTF-8"), + ("LC_ALL", "C.UTF-8"), + ("COLORTERM", ""), + ("PAGER", "cat"), + ("GIT_PAGER", "cat"), + ("GH_PAGER", "cat"), + ("CODEX_CI", "1"), +]; + +#[derive(Debug, PartialEq, Eq)] struct ShellSpec { program: String, args: Vec, } fn resolve_shell(shell_override: Option<&str>, login: bool) -> ShellSpec { + let default_shell = if cfg!(windows) { + "powershell".to_string() + } else { + std::env::var("SHELL") + .ok() + .filter(|shell| !shell.is_empty()) + .unwrap_or_else(|| "bash".to_string()) + }; + resolve_shell_with_default(shell_override, login, &default_shell) +} + +fn resolve_shell_with_default( + shell_override: Option<&str>, + login: bool, + default_shell: &str, +) -> ShellSpec { if let Some(shell) = shell_override { - let mut args = Vec::new(); - if login { - args.push("-l".to_string()); - } - args.push("-c".to_string()); return ShellSpec { program: shell.to_string(), - args, + args: shell_args(shell, login), }; } - let shell = if cfg!(windows) { "powershell" } else { "bash" }; - let mut args = Vec::new(); - if login && !cfg!(windows) { - args.push("-l".to_string()); - } - args.push("-c".to_string()); ShellSpec { - program: shell.to_string(), - args, + program: default_shell.to_string(), + args: shell_args(default_shell, login), + } +} + +fn shell_args(shell: &str, login: bool) -> Vec { + let shell_name = shell_name(shell); + + if is_powershell_name(&shell_name) { + let mut args = Vec::new(); + if !login { + args.push("-NoProfile".to_string()); + } + args.push("-Command".to_string()); + return args; + } + + if shell_name == "cmd" { + return vec!["/c".to_string()]; + } + + vec![if login { "-lc" } else { "-c" }.to_string()] +} + +fn shell_name(shell: &str) -> String { + Path::new(shell) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase() +} + +fn is_powershell_name(name: &str) -> bool { + name == "powershell" || name == "pwsh" +} + +fn command_for_shell(cmd: &str, shell_spec: &ShellSpec) -> String { + if !is_powershell_name(&shell_name(&shell_spec.program)) { + return cmd.to_string(); + } + let trimmed = cmd.trim_start(); + if trimmed.starts_with(POWERSHELL_UTF8_OUTPUT_PREFIX) { + cmd.to_string() + } else { + format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}{cmd}") } } /// Max time (in seconds) a process can live without any write_stdin interaction. const IDLE_TIMEOUT_SECS: u64 = 1800; +fn unified_exec_pty_system() -> Box { + #[cfg(windows)] + { + Box::new(super::windows_pty::ConPtySystem) + } + + #[cfg(not(windows))] + { + portable_pty::native_pty_system() + } +} + +struct PtyKeepAlive { + _master: Box, + #[cfg(windows)] + _slave: Box, +} + pub struct UnifiedExecProcess { exit_code: Arc, terminated_flag: Arc, stdin_writer: Arc>>>, output_tx: broadcast::Sender>, + output_buffer: Arc>, + last_stdin_interaction: Arc>, process_id: i32, + tty: bool, + _pty_keep_alive: Mutex>, } impl UnifiedExecProcess { @@ -64,12 +153,30 @@ impl UnifiedExecProcess { cwd: &Path, shell: Option<&str>, login: bool, + tty: bool, + ) -> Result<(Self, broadcast::Receiver>), String> { + if tty { + Self::spawn_pty(process_id, cmd, cwd, shell, login) + } else { + Self::spawn_piped(process_id, cmd, cwd, shell, login) + } + } + + fn spawn_pty( + process_id: i32, + cmd: &str, + cwd: &Path, + shell: Option<&str>, + login: bool, ) -> Result<(Self, broadcast::Receiver>), String> { let (output_tx, _output_rx) = broadcast::channel(256); + let output_buffer = Arc::new(AsyncMutex::new(HeadTailBuffer::new())); let terminated_flag = Arc::new(AtomicBool::new(false)); let terminated_flag_clone = Arc::clone(&terminated_flag); + let last_stdin_interaction = Arc::new(Mutex::new(Instant::now())); + let last_stdin_interaction_clone = Arc::clone(&last_stdin_interaction); - let pty_system = native_pty_system(); + let pty_system = unified_exec_pty_system(); let pair = pty_system .openpty(PtySize { rows: PTY_ROWS, @@ -82,19 +189,19 @@ impl UnifiedExecProcess { let shell_spec = resolve_shell(shell, login); let mut builder = CommandBuilder::new(&shell_spec.program); builder.args(&shell_spec.args); - builder.arg(cmd); + builder.arg(command_for_shell(cmd, &shell_spec)); builder.cwd(cwd); + for (key, value) in UNIFIED_EXEC_ENV { + builder.env(key, value); + } if cfg!(windows) { builder.env("PYTHONUTF8", "1"); - builder.env("TERM", "xterm-256color"); - builder.env("COLORTERM", "truecolor"); } let mut child = pair .slave .spawn_command(builder) .map_err(|e| format!("failed to spawn PTY command: {e}"))?; - drop(pair.slave); let mut reader = pair .master @@ -105,6 +212,11 @@ impl UnifiedExecProcess { .master .take_writer() .map_err(|e| format!("failed to take PTY writer: {e}"))?; + let keep_alive = PtyKeepAlive { + _master: pair.master, + #[cfg(windows)] + _slave: pair.slave, + }; let (tokio_tx, mut tokio_rx) = tokio::sync::mpsc::unbounded_channel::>(); @@ -132,17 +244,27 @@ impl UnifiedExecProcess { let exit_code = Arc::new(std::sync::atomic::AtomicI32::new(-1)); let exit_code_clone = Arc::clone(&exit_code); let output_tx_clone = output_tx.clone(); + let output_buffer_clone = Arc::clone(&output_buffer); let idle_timeout = Duration::from_secs(IDLE_TIMEOUT_SECS); - let started_at = std::time::Instant::now(); - // Background task: forward tokio::mpsc -> broadcast, handle shutdown/exit/idle timeout tokio::spawn(async move { + let (wait_tx, mut wait_rx) = tokio::sync::oneshot::channel(); + let mut child_killer = child.clone_killer(); + let _wait_thread = std::thread::spawn(move || { + let code = child.wait().ok().map(|status| status.exit_code() as i32); + let _ = wait_tx.send(code); + }); + loop { tokio::select! { _ = async { while !terminated_flag_clone.load(Ordering::SeqCst) { - if started_at.elapsed() >= idle_timeout { + let idle_for = last_stdin_interaction_clone + .lock() + .map(|last| last.elapsed()) + .unwrap_or(idle_timeout); + if idle_for >= idle_timeout { break; } tokio::time::sleep(Duration::from_millis(100)).await; @@ -151,36 +273,25 @@ impl UnifiedExecProcess { break; } Some(bytes) = tokio_rx.recv() => { + output_buffer_clone.lock().await.push(&bytes); let _ = output_tx_clone.send(bytes); } + result = &mut wait_rx => { + let code = result.ok().flatten().unwrap_or(-1); + sleep(Duration::from_millis(PTY_TRAILING_OUTPUT_GRACE_MS)).await; + while let Ok(bytes) = tokio_rx.try_recv() { + output_buffer_clone.lock().await.push(&bytes); + let _ = output_tx_clone.send(bytes); + } + exit_code_clone.store(code, std::sync::atomic::Ordering::SeqCst); + break; + } else => break, } } - if let Ok(status) = child.try_wait() { - if let Some(s) = status { - exit_code_clone - .store(s.exit_code() as i32, std::sync::atomic::Ordering::SeqCst); - } else { - let _ = child.kill(); - let _ = child.wait(); - // After wait(), try_wait() should return exit status - if let Ok(Some(s)) = child.try_wait() { - exit_code_clone - .store(s.exit_code() as i32, std::sync::atomic::Ordering::SeqCst); - } else { - exit_code_clone.store(-1, std::sync::atomic::Ordering::SeqCst); - } - } - } else { - let _ = child.kill(); - let _ = child.wait(); - if let Ok(Some(s)) = child.try_wait() { - exit_code_clone - .store(s.exit_code() as i32, std::sync::atomic::Ordering::SeqCst); - } else { - exit_code_clone.store(-1, std::sync::atomic::Ordering::SeqCst); - } + if exit_code_clone.load(std::sync::atomic::Ordering::SeqCst) < 0 { + let _ = child_killer.kill(); } // Mark as no longer running (both normal exit and forced kill) terminated_flag_clone.store(true, std::sync::atomic::Ordering::SeqCst); @@ -194,24 +305,110 @@ impl UnifiedExecProcess { terminated_flag, stdin_writer: Arc::new(Mutex::new(Some(writer))), output_tx, + output_buffer, + last_stdin_interaction, process_id, + tty: true, + _pty_keep_alive: Mutex::new(Some(keep_alive)), + }, + proc_output_rx, + )) + } + + fn spawn_piped( + process_id: i32, + cmd: &str, + cwd: &Path, + shell: Option<&str>, + login: bool, + ) -> Result<(Self, broadcast::Receiver>), String> { + let (output_tx, _output_rx) = broadcast::channel(256); + let output_buffer = Arc::new(AsyncMutex::new(HeadTailBuffer::new())); + let terminated_flag = Arc::new(AtomicBool::new(false)); + let exit_code = Arc::new(std::sync::atomic::AtomicI32::new(-1)); + + let shell_spec = resolve_shell(shell, login); + let mut command = tokio::process::Command::new(&shell_spec.program); + command.args(&shell_spec.args); + command.arg(command_for_shell(cmd, &shell_spec)); + command.current_dir(cwd); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + for (key, value) in UNIFIED_EXEC_ENV { + command.env(key, value); + } + if cfg!(windows) { + command.env("PYTHONUTF8", "1"); + } + + let mut child = command + .spawn() + .map_err(|e| format!("failed to spawn command: {e}"))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + if let Some(stdout) = stdout { + spawn_pipe_reader(stdout, output_tx.clone(), Arc::clone(&output_buffer)); + } + if let Some(stderr) = stderr { + spawn_pipe_reader(stderr, output_tx.clone(), Arc::clone(&output_buffer)); + } + + let terminated_flag_clone = Arc::clone(&terminated_flag); + let exit_code_clone = Arc::clone(&exit_code); + tokio::spawn(async move { + let code = tokio::select! { + status = child.wait() => status.ok().and_then(|status| status.code()), + _ = async { + while !terminated_flag_clone.load(Ordering::SeqCst) { + sleep(Duration::from_millis(100)).await; + } + } => { + let _ = child.kill().await; + child.wait().await.ok().and_then(|status| status.code()) + } + }; + + exit_code_clone.store(code.unwrap_or(-1), Ordering::SeqCst); + terminated_flag_clone.store(true, Ordering::SeqCst); + }); + + let proc_output_rx = output_tx.subscribe(); + + Ok(( + UnifiedExecProcess { + exit_code, + terminated_flag, + stdin_writer: Arc::new(Mutex::new(None)), + output_tx, + output_buffer, + last_stdin_interaction: Arc::new(Mutex::new(Instant::now())), + process_id, + tty: false, + _pty_keep_alive: Mutex::new(None), }, proc_output_rx, )) } pub fn write_stdin(&self, chars: &str) -> Result<(), String> { + let bytes = stdin_bytes_for_pty(chars); let mut guard = self .stdin_writer .lock() .map_err(|e| format!("lock error: {e}"))?; if let Some(writer) = guard.as_mut() { writer - .write_all(chars.as_bytes()) + .write_all(&bytes) .map_err(|e| format!("failed to write to stdin: {e}"))?; writer .flush() .map_err(|e| format!("failed to flush stdin: {e}"))?; + *self + .last_stdin_interaction + .lock() + .map_err(|e| format!("lock error: {e}"))? = Instant::now(); Ok(()) } else { Err("stdin is closed for this session".to_string()) @@ -237,11 +434,41 @@ impl UnifiedExecProcess { self.process_id } + pub fn tty(&self) -> bool { + self.tty + } + pub fn subscribe(&self) -> broadcast::Receiver> { self.output_tx.subscribe() } } +fn stdin_bytes_for_pty(chars: &str) -> Vec { + #[cfg(windows)] + { + let mut bytes = Vec::with_capacity(chars.len()); + let mut previous_was_cr = false; + for byte in chars.bytes() { + if byte == b'\n' { + if !previous_was_cr { + bytes.push(b'\r'); + } + bytes.push(b'\n'); + previous_was_cr = false; + continue; + } + bytes.push(byte); + previous_was_cr = byte == b'\r'; + } + bytes + } + + #[cfg(not(windows))] + { + chars.as_bytes().to_vec() + } +} + impl Drop for UnifiedExecProcess { fn drop(&mut self) { self.terminate(); @@ -255,15 +482,18 @@ pub async fn collect_output( max_output_tokens: usize, ) -> ProcessOutput { let started = Instant::now(); - let mut buf = HeadTailBuffer::new(); + let mut collected = Vec::new(); let deadline = Duration::from_millis(yield_time_ms); loop { + { + let mut pending = process.output_buffer.lock().await; + collected.extend_from_slice(&pending.drain_collect_bytes()); + } + loop { match output_rx.try_recv() { - Ok(bytes) => { - buf.push(&bytes); - } + Ok(_bytes) => {} Err(broadcast::error::TryRecvError::Empty) => break, Err(broadcast::error::TryRecvError::Closed) => { let _ = output_rx.try_recv(); @@ -276,8 +506,13 @@ pub async fn collect_output( let done = !process.is_running() || (process.exit_code().is_some() && output_rx.is_empty()); if done { + sleep(Duration::from_millis(50)).await; + { + let mut pending = process.output_buffer.lock().await; + collected.extend_from_slice(&pending.drain_collect_bytes()); + } while let Ok(bytes) = output_rx.try_recv() { - buf.push(&bytes); + let _ = bytes; } break; } @@ -289,33 +524,119 @@ pub async fn collect_output( sleep(Duration::from_millis(10)).await; } - let mut output = buf.collect(); - - let max_chars = max_output_tokens.saturating_mul(4); - let truncated = output.len() > max_chars; - if truncated { - output.truncate(max_chars); - output.push_str("\n\n... [truncated]"); - } + let original_token_count = approximate_token_count(collected.len()); + let raw_output = String::from_utf8_lossy(&collected).to_string(); + let (output, truncated) = formatted_truncate_tokens(&raw_output, max_output_tokens); ProcessOutput { output, exit_code: process.exit_code(), wall_time_secs: started.elapsed().as_secs_f64(), truncated, + original_token_count, + } +} + +fn approximate_token_count(byte_len: usize) -> usize { + if byte_len == 0 { + 0 + } else { + byte_len.div_ceil(4) + } +} + +fn formatted_truncate_tokens(content: &str, max_output_tokens: usize) -> (String, bool) { + let max_bytes = max_output_tokens.saturating_mul(4); + if content.len() <= max_bytes { + return (content.to_string(), false); + } + + let total_lines = content.lines().count(); + let truncated = truncate_middle_with_token_marker(content, max_bytes); + ( + format!("Total output lines: {total_lines}\n\n{truncated}"), + true, + ) +} + +fn truncate_middle_with_token_marker(content: &str, max_bytes: usize) -> String { + if max_bytes == 0 { + return format!( + "…{} tokens truncated…", + approximate_token_count(content.len()) + ); + } + + let head_budget = max_bytes / 2; + let tail_budget = max_bytes.saturating_sub(head_budget); + let head_end = floor_char_boundary(content, head_budget); + let tail_start = ceil_char_boundary(content, content.len().saturating_sub(tail_budget)); + let omitted_bytes = tail_start.saturating_sub(head_end); + format!( + "{}…{} tokens truncated…{}", + &content[..head_end], + approximate_token_count(omitted_bytes), + &content[tail_start..] + ) +} + +fn floor_char_boundary(value: &str, mut index: usize) -> usize { + index = index.min(value.len()); + while index > 0 && !value.is_char_boundary(index) { + index -= 1; + } + index +} + +fn ceil_char_boundary(value: &str, mut index: usize) -> usize { + index = index.min(value.len()); + while index < value.len() && !value.is_char_boundary(index) { + index += 1; } + index +} + +fn spawn_pipe_reader( + mut stream: R, + output_tx: broadcast::Sender>, + output_buffer: Arc>, +) where + R: AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let mut buf = [0u8; PTY_READ_BUF]; + loop { + match stream.read(&mut buf).await { + Ok(0) => break, + Ok(size) => { + let bytes = buf[..size].to_vec(); + output_buffer.lock().await.push(&bytes); + let _ = output_tx.send(bytes); + } + Err(_) => break, + } + } + }); } #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use std::path::Path; #[tokio::test] async fn process_spawn_and_exit() { let cmd = "echo hello"; - let (proc, mut rx) = UnifiedExecProcess::spawn(1, cmd, Path::new("."), None, false) - .expect("spawn should succeed"); + let (proc, mut rx) = UnifiedExecProcess::spawn( + 1, + cmd, + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); // Wait for process to finish let mut waited = 0u64; @@ -335,12 +656,184 @@ mod tests { } } + #[tokio::test] + #[cfg(unix)] + async fn process_non_tty_captures_output_without_early_subscription() { + let (proc, mut rx) = UnifiedExecProcess::spawn( + 4, + "printf buffered-output", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let output = collect_output(&mut rx, &proc, 250, 1000).await; + + assert_eq!(output.output, "buffered-output"); + assert_eq!(output.exit_code, Some(0)); + } + + #[tokio::test] + async fn process_non_tty_rejects_stdin_write() { + let (proc, _rx) = UnifiedExecProcess::spawn( + 5, + "echo test", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); + + assert_eq!( + proc.write_stdin("input\n"), + Err("stdin is closed for this session".to_string()) + ); + } + + #[tokio::test] + #[cfg(unix)] + async fn process_non_tty_applies_codex_unified_exec_env() { + let (proc, mut rx) = UnifiedExecProcess::spawn( + 6, + "printf '%s|%s|%s' \"$NO_COLOR\" \"$TERM\" \"$PAGER\"", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); + + let output = collect_output(&mut rx, &proc, 1000, 1000).await; + + assert_eq!(output.output, "1|dumb|cat"); + assert_eq!(output.exit_code, Some(0)); + } + + #[test] + fn formatted_truncate_tokens_keeps_head_tail_and_line_count() { + let content = "alpha beta gamma delta epsilon\nzeta eta theta iota kappa"; + + let (output, truncated) = formatted_truncate_tokens(content, 5); + + assert!(truncated); + assert!(output.starts_with("Total output lines: 2\n\nalpha")); + assert!(output.contains("tokens truncated")); + assert!(output.ends_with("iota kappa")); + } + + #[test] + fn formatted_truncate_tokens_preserves_utf8_boundaries() { + let content = "😀😀😀😀😀😀😀😀😀😀"; + + let (output, truncated) = formatted_truncate_tokens(content, 2); + + assert!(truncated); + assert!(output.contains("tokens truncated")); + } + + #[test] + fn resolve_shell_uses_user_shell_default_and_codex_style_args() { + assert_eq!( + resolve_shell_with_default( + /*shell_override*/ None, /*login*/ true, "/bin/zsh" + ), + ShellSpec { + program: "/bin/zsh".to_string(), + args: vec!["-lc".to_string()], + } + ); + assert_eq!( + resolve_shell_with_default( + /*shell_override*/ None, /*login*/ false, "/bin/zsh" + ), + ShellSpec { + program: "/bin/zsh".to_string(), + args: vec!["-c".to_string()], + } + ); + } + + #[test] + fn resolve_shell_uses_powershell_profile_only_for_login() { + assert_eq!( + resolve_shell_with_default( + /*shell_override*/ Some("pwsh"), + /*login*/ true, + "/bin/zsh" + ), + ShellSpec { + program: "pwsh".to_string(), + args: vec!["-Command".to_string()], + } + ); + assert_eq!( + resolve_shell_with_default( + /*shell_override*/ Some("pwsh"), + /*login*/ false, + "/bin/zsh", + ), + ShellSpec { + program: "pwsh".to_string(), + args: vec!["-NoProfile".to_string(), "-Command".to_string()], + } + ); + } + + #[test] + fn command_for_shell_prefixes_powershell_utf8_output() { + let shell_spec = ShellSpec { + program: "pwsh".to_string(), + args: vec!["-Command".to_string()], + }; + + assert_eq!( + command_for_shell("Write-Output hi", &shell_spec), + format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}Write-Output hi") + ); + assert_eq!( + command_for_shell( + &format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}Write-Output hi"), + &shell_spec + ), + format!("{POWERSHELL_UTF8_OUTPUT_PREFIX}Write-Output hi") + ); + } + + #[test] + fn command_for_shell_leaves_posix_shell_unchanged() { + let shell_spec = ShellSpec { + program: "/bin/zsh".to_string(), + args: vec!["-lc".to_string()], + }; + + assert_eq!(command_for_shell("echo hi", &shell_spec), "echo hi"); + } + + #[cfg(windows)] + #[test] + fn stdin_bytes_for_windows_pty_uses_carriage_return() { + assert_eq!(stdin_bytes_for_pty("Alice\n"), b"Alice\r\n"); + assert_eq!(stdin_bytes_for_pty("Alice\r\n"), b"Alice\r\n"); + } + #[tokio::test] async fn process_terminate_works() { // Only run on platforms where we have reliable PTY support if cfg!(target_os = "linux") { - let (proc, _rx) = UnifiedExecProcess::spawn(2, "sleep 60", Path::new("."), None, false) - .expect("spawn should succeed"); + let (proc, _rx) = UnifiedExecProcess::spawn( + 2, + "sleep 60", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ true, + ) + .expect("spawn should succeed"); assert!(proc.is_running()); proc.terminate(); @@ -359,13 +852,78 @@ mod tests { async fn process_write_stdin_before_exit() { // Only run on Unix where cat + PTY stdin works reliably if cfg!(target_os = "linux") { - let (proc, _rx) = UnifiedExecProcess::spawn(3, "cat", Path::new("."), None, false) - .expect("spawn should succeed"); + let (proc, _rx) = UnifiedExecProcess::spawn( + 3, + "cat", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ true, + ) + .expect("spawn should succeed"); tokio::time::sleep(Duration::from_millis(300)).await; + *proc + .last_stdin_interaction + .lock() + .expect("last stdin interaction lock should not be poisoned") = + Instant::now() - Duration::from_secs(60); let result = proc.write_stdin("test data\n"); assert!(result.is_ok(), "write_stdin failed: {:?}", result); + let idle_for = proc + .last_stdin_interaction + .lock() + .expect("last stdin interaction lock should not be poisoned") + .elapsed(); + assert!(idle_for < Duration::from_secs(1)); } } + + #[cfg(windows)] + #[tokio::test] + async fn process_windows_pty_echo_exits() { + let (proc, mut rx) = UnifiedExecProcess::spawn( + 4, + "Write-Output unified-pty-ok", + Path::new("."), + /*shell*/ Some("powershell"), + /*login*/ false, + /*tty*/ true, + ) + .expect("spawn should succeed"); + + let output = collect_output(&mut rx, &proc, 5_000, 1_000).await; + + assert_eq!(output.exit_code, Some(0)); + assert!(output.output.contains("unified-pty-ok")); + } + + #[cfg(windows)] + #[tokio::test] + async fn process_windows_pty_read_host_accepts_stdin() { + let (proc, mut rx) = UnifiedExecProcess::spawn( + 5, + "Write-Host \"Enter name:\"; $name = Read-Host; Write-Host \"Hello, $name\"", + Path::new("."), + /*shell*/ Some("powershell"), + /*login*/ false, + /*tty*/ true, + ) + .expect("spawn should succeed"); + + let initial = collect_output(&mut rx, &proc, 2_000, 1_000).await; + assert!(initial.output.contains("Enter name:")); + + proc.write_stdin("Alice\n") + .expect("stdin write should work"); + let output = collect_output(&mut rx, &proc, 5_000, 1_000).await; + + assert_eq!(output.exit_code, Some(0)); + assert!( + output.output.contains("Hello, Alice"), + "missing greeting in output: {:?}", + output.output + ); + } } diff --git a/crates/tools/src/unified_exec/store.rs b/crates/tools/src/unified_exec/store.rs index 64587a7..4ca6a34 100644 --- a/crates/tools/src/unified_exec/store.rs +++ b/crates/tools/src/unified_exec/store.rs @@ -1,7 +1,9 @@ +use std::cmp::Reverse; use std::collections::HashMap; +use std::collections::HashSet; use std::sync::Arc; -use std::sync::atomic::{AtomicI32, Ordering}; +use rand::Rng; use tokio::sync::RwLock; use tracing::warn; @@ -10,38 +12,51 @@ use super::{MAX_PROCESSES, WARNING_PROCESSES}; struct ProcessEntry { process: Arc, - created_at: std::time::Instant, + last_used: std::time::Instant, } +#[derive(Clone, Copy)] +struct ProcessPruneMeta { + process_id: i32, + last_used: std::time::Instant, + exited: bool, +} + +const PROTECTED_RECENT_PROCESSES: usize = 8; + pub struct ProcessStore { processes: RwLock>, - next_id: AtomicI32, + reserved_process_ids: RwLock>, } impl ProcessStore { pub fn new() -> Self { ProcessStore { processes: RwLock::new(HashMap::new()), - next_id: AtomicI32::new(1000), + reserved_process_ids: RwLock::new(HashSet::new()), } } pub async fn allocate(&self, process: Arc) -> i32 { + let Some(id) = self.reserve_process_id().await else { + process.terminate(); + return 0; + }; + self.insert_reserved(id, process).await; + id + } + + pub async fn reserve_process_id(&self) -> Option { + let mut reserved = self.reserved_process_ids.write().await; let mut map = self.processes.write().await; - let id = self.next_id.fetch_add(1, Ordering::SeqCst); if map.len() >= MAX_PROCESSES { - self.prune_locked(&mut map); + self.prune_process_if_needed(&mut map); } if map.len() >= MAX_PROCESSES { - warn!("max unified exec processes ({MAX_PROCESSES}) reached, removing oldest"); - if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.created_at) { - let oldest_id = *oldest.0; - if let Some(entry) = map.remove(&oldest_id) { - entry.process.terminate(); - } - } + warn!("max unified exec processes ({MAX_PROCESSES}) reached; cannot allocate process"); + return None; } if map.len() >= WARNING_PROCESSES { @@ -52,28 +67,62 @@ impl ProcessStore { ); } + let id = loop { + let candidate = rand::rng().random_range(1_000..100_000); + if !map.contains_key(&candidate) && !reserved.contains(&candidate) { + break candidate; + } + }; + reserved.insert(id); + Some(id) + } + + pub async fn insert_reserved(&self, id: i32, process: Arc) { + self.reserved_process_ids.write().await.remove(&id); + let mut map = self.processes.write().await; map.insert( id, ProcessEntry { process, - created_at: std::time::Instant::now(), + last_used: std::time::Instant::now(), }, ); - id + } + + pub async fn release_reserved(&self, id: i32) { + self.reserved_process_ids.write().await.remove(&id); } pub async fn get(&self, id: i32) -> Option> { - let map = self.processes.read().await; - map.get(&id).map(|entry| Arc::clone(&entry.process)) + let mut map = self.processes.write().await; + map.get_mut(&id).map(|entry| { + entry.last_used = std::time::Instant::now(); + Arc::clone(&entry.process) + }) } pub async fn remove(&self, id: i32) { + self.reserved_process_ids.write().await.remove(&id); let mut map = self.processes.write().await; if let Some(entry) = map.remove(&id) { entry.process.terminate(); } } + pub async fn terminate_all(&self) { + self.reserved_process_ids.write().await.clear(); + let processes = { + let mut map = self.processes.write().await; + map.drain() + .map(|(_id, entry)| entry.process) + .collect::>() + }; + + for process in processes { + process.terminate(); + } + } + pub async fn len(&self) -> usize { self.processes.read().await.len() } @@ -99,6 +148,53 @@ impl ProcessStore { } } } + + fn prune_process_if_needed(&self, map: &mut HashMap) { + if map.len() < MAX_PROCESSES { + return; + } + let meta = map + .iter() + .map(|(process_id, entry)| ProcessPruneMeta { + process_id: *process_id, + last_used: entry.last_used, + exited: !entry.process.is_running(), + }) + .collect::>(); + if let Some(process_id) = process_id_to_prune_from_meta(&meta) + && let Some(entry) = map.remove(&process_id) + { + entry.process.terminate(); + } + } +} + +fn process_id_to_prune_from_meta(meta: &[ProcessPruneMeta]) -> Option { + if meta.is_empty() { + return None; + } + + let mut by_recency = meta.to_vec(); + by_recency.sort_by_key(|entry| Reverse(entry.last_used)); + let protected = by_recency + .iter() + .take(PROTECTED_RECENT_PROCESSES) + .map(|entry| entry.process_id) + .collect::>(); + + let mut lru = meta.to_vec(); + lru.sort_by_key(|entry| entry.last_used); + + if let Some(entry) = lru + .iter() + .find(|entry| !protected.contains(&entry.process_id) && entry.exited) + { + return Some(entry.process_id); + } + + lru.into_iter() + .find(|entry| !protected.contains(&entry.process_id)) + .map(|entry| entry.process_id) } impl Default for ProcessStore { @@ -111,11 +207,20 @@ impl Default for ProcessStore { mod tests { use super::*; use crate::unified_exec::process::UnifiedExecProcess; + use pretty_assertions::assert_eq; use std::path::Path; + use std::time::Duration; fn spawn_echo() -> UnifiedExecProcess { - let (proc, _rx) = UnifiedExecProcess::spawn(1, "echo test", Path::new("."), None, false) - .expect("spawn should succeed"); + let (proc, _rx) = UnifiedExecProcess::spawn( + 1, + "echo test", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); proc } @@ -128,6 +233,26 @@ mod tests { assert!(store.get(9999).await.is_none()); } + #[tokio::test] + async fn store_reserved_insert_preserves_process_id() { + let store = ProcessStore::new(); + let id = store.reserve_process_id().await.expect("reserve id"); + let (proc, _rx) = UnifiedExecProcess::spawn( + id, + "echo test", + Path::new("."), + /*shell*/ None, + /*login*/ false, + /*tty*/ false, + ) + .expect("spawn should succeed"); + + store.insert_reserved(id, Arc::new(proc)).await; + let proc = store.get(id).await.expect("stored process"); + + assert_eq!(proc.process_id(), id); + } + #[tokio::test] async fn store_remove_terminates() { let store = ProcessStore::new(); @@ -137,6 +262,22 @@ mod tests { assert!(store.get(id).await.is_none()); } + #[tokio::test] + async fn store_terminate_all_drains_processes_and_reservations() { + let store = ProcessStore::new(); + let reserved_id = store.reserve_process_id().await.expect("reserve id"); + let proc = Arc::new(spawn_echo()); + let id = store.allocate(Arc::clone(&proc)).await; + + store.terminate_all().await; + + assert_eq!(store.len().await, 0); + assert!(store.get(id).await.is_none()); + assert!(!proc.is_running()); + assert!(store.reserve_process_id().await.is_some()); + store.release_reserved(reserved_id).await; + } + #[tokio::test] async fn store_len() { let store = ProcessStore::new(); @@ -203,4 +344,32 @@ mod tests { assert_eq!(count_before, count_after); assert!(count_before >= 1); } + + #[test] + fn process_prune_prefers_exited_lru_outside_protected_recent_set() { + let now = std::time::Instant::now(); + let meta = (0..MAX_PROCESSES) + .map(|index| ProcessPruneMeta { + process_id: index as i32, + last_used: now + Duration::from_millis(index as u64), + exited: index == 10 || index == 20, + }) + .collect::>(); + + assert_eq!(process_id_to_prune_from_meta(&meta), Some(10)); + } + + #[test] + fn process_prune_protects_recent_exited_processes() { + let now = std::time::Instant::now(); + let meta = (0..MAX_PROCESSES) + .map(|index| ProcessPruneMeta { + process_id: index as i32, + last_used: now + Duration::from_millis(index as u64), + exited: index == MAX_PROCESSES - 1, + }) + .collect::>(); + + assert_eq!(process_id_to_prune_from_meta(&meta), Some(0)); + } } diff --git a/crates/tools/src/unified_exec/windows_pty.rs b/crates/tools/src/unified_exec/windows_pty.rs new file mode 100644 index 0000000..56933a7 --- /dev/null +++ b/crates/tools/src/unified_exec/windows_pty.rs @@ -0,0 +1,564 @@ +#![cfg(windows)] +#![allow(clippy::upper_case_acronyms)] + +use anyhow::{Error, bail, ensure}; +use filedescriptor::{FileDescriptor, OwnedHandle, Pipe}; +use lazy_static::lazy_static; +use portable_pty::cmdbuilder::CommandBuilder; +use portable_pty::{ + Child, ChildKiller, ExitStatus, MasterPty, PtyPair, PtySize, PtySystem, SlavePty, +}; +use shared_library::shared_library; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::io::{Error as IoError, Result as IoResult}; +use std::mem; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::os::windows::io::{AsRawHandle, FromRawHandle}; +use std::path::Path; +use std::ptr; +use std::sync::{Arc, Mutex}; +use winapi::shared::minwindef::DWORD; +use winapi::shared::ntdef::NTSTATUS; +use winapi::shared::ntstatus::STATUS_SUCCESS; +use winapi::shared::winerror::{HRESULT, S_OK}; +use winapi::um::handleapi::*; +use winapi::um::minwinbase::STILL_ACTIVE; +use winapi::um::processthreadsapi::*; +use winapi::um::synchapi::WaitForSingleObject; +use winapi::um::winbase::{ + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE, STARTF_USESTDHANDLES, + STARTUPINFOEXW, +}; +use winapi::um::wincon::COORD; +use winapi::um::winnt::{HANDLE, OSVERSIONINFOW}; + +type HPCON = HANDLE; + +const PSEUDOCONSOLE_RESIZE_QUIRK: DWORD = 0x2; +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; +const MIN_CONPTY_BUILD: u32 = 17_763; + +shared_library!(ConPtyFuncs, + pub fn CreatePseudoConsole( + size: COORD, + hInput: HANDLE, + hOutput: HANDLE, + flags: DWORD, + hpc: *mut HPCON + ) -> HRESULT, + pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT, + pub fn ClosePseudoConsole(hpc: HPCON), +); + +shared_library!(Ntdll, + pub fn RtlGetVersion(version_info: *mut OSVERSIONINFOW) -> NTSTATUS, +); + +lazy_static! { + static ref CONPTY: ConPtyFuncs = load_conpty(); +} + +fn load_conpty() -> ConPtyFuncs { + let kernel = ConPtyFuncs::open(Path::new("kernel32.dll")).expect( + "this system does not support conpty. Windows 10 October 2018 or newer is required", + ); + + ConPtyFuncs::open(Path::new("conpty.dll")).unwrap_or(kernel) +} + +pub fn conpty_supported() -> bool { + windows_build_number().is_some_and(|build| build >= MIN_CONPTY_BUILD) +} + +fn windows_build_number() -> Option { + let ntdll = Ntdll::open(Path::new("ntdll.dll")).ok()?; + let mut info: OSVERSIONINFOW = unsafe { mem::zeroed() }; + info.dwOSVersionInfoSize = mem::size_of::() as u32; + let status = unsafe { (ntdll.RtlGetVersion)(&mut info) }; + (status == STATUS_SUCCESS).then_some(info.dwBuildNumber) +} + +pub struct ConPtySystem; + +impl PtySystem for ConPtySystem { + fn openpty(&self, size: PtySize) -> anyhow::Result { + if !conpty_supported() { + bail!("ConPTY requires Windows 10 October 2018 or newer"); + } + + let stdin = Pipe::new()?; + let stdout = Pipe::new()?; + let con = PsuedoCon::new( + COORD { + X: size.cols as i16, + Y: size.rows as i16, + }, + stdin.read, + stdout.write, + )?; + + let master = ConPtyMasterPty { + inner: Arc::new(Mutex::new(Inner { + con, + readable: stdout.read, + writable: Some(stdin.write), + size, + })), + }; + let slave = ConPtySlavePty { + inner: Arc::clone(&master.inner), + }; + + Ok(PtyPair { + master: Box::new(master), + slave: Box::new(slave), + }) + } +} + +struct Inner { + con: PsuedoCon, + readable: FileDescriptor, + writable: Option, + size: PtySize, +} + +pub struct ConPtyMasterPty { + inner: Arc>, +} + +pub struct ConPtySlavePty { + inner: Arc>, +} + +impl MasterPty for ConPtyMasterPty { + fn resize(&self, size: PtySize) -> anyhow::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner.con.resize(COORD { + X: size.cols as i16, + Y: size.rows as i16, + })?; + inner.size = size; + Ok(()) + } + + fn get_size(&self) -> Result { + Ok(self.inner.lock().unwrap().size) + } + + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(self.inner.lock().unwrap().readable.try_clone()?)) + } + + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new( + self.inner + .lock() + .unwrap() + .writable + .take() + .ok_or_else(|| anyhow::anyhow!("writer already taken"))?, + )) + } +} + +impl SlavePty for ConPtySlavePty { + fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result> { + let child = self.inner.lock().unwrap().con.spawn_command(cmd)?; + Ok(Box::new(child)) + } +} + +pub struct PsuedoCon { + con: HPCON, + _input: FileDescriptor, + _output: FileDescriptor, +} + +unsafe impl Send for PsuedoCon {} +unsafe impl Sync for PsuedoCon {} + +impl Drop for PsuedoCon { + fn drop(&mut self) { + unsafe { (CONPTY.ClosePseudoConsole)(self.con) }; + } +} + +impl PsuedoCon { + fn new(size: COORD, input: FileDescriptor, output: FileDescriptor) -> Result { + let mut con: HPCON = INVALID_HANDLE_VALUE; + let result = unsafe { + (CONPTY.CreatePseudoConsole)( + size, + input.as_raw_handle() as _, + output.as_raw_handle() as _, + PSEUDOCONSOLE_RESIZE_QUIRK, + &mut con, + ) + }; + ensure!( + result == S_OK, + "failed to create pseudo console: HRESULT {result}" + ); + Ok(Self { + con, + _input: input, + _output: output, + }) + } + + fn resize(&self, size: COORD) -> Result<(), Error> { + let result = unsafe { (CONPTY.ResizePseudoConsole)(self.con, size) }; + ensure!( + result == S_OK, + "failed to resize console to {}x{}: HRESULT: {}", + size.X, + size.Y, + result + ); + Ok(()) + } + + fn spawn_command(&self, cmd: CommandBuilder) -> anyhow::Result { + let mut startup_info: STARTUPINFOEXW = unsafe { mem::zeroed() }; + startup_info.StartupInfo.cb = mem::size_of::() as u32; + startup_info.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup_info.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + startup_info.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + startup_info.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + + let mut attrs = ProcThreadAttributeList::with_capacity(/*num_attributes*/ 1)?; + attrs.set_pty(self.con)?; + startup_info.lpAttributeList = attrs.as_mut_ptr(); + + let mut process_info: PROCESS_INFORMATION = unsafe { mem::zeroed() }; + let (mut exe, mut cmdline) = build_cmdline(&cmd)?; + let cmd_os = OsString::from_wide(&cmdline); + let cwd = resolve_current_directory(&cmd); + let mut env_block = build_environment_block(&cmd); + + let res = unsafe { + CreateProcessW( + exe.as_mut_ptr(), + cmdline.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + env_block.as_mut_ptr() as *mut _, + cwd.as_ref().map_or(ptr::null(), Vec::as_ptr), + &mut startup_info.StartupInfo, + &mut process_info, + ) + }; + if res == 0 { + let err = IoError::last_os_error(); + let msg = format!( + "CreateProcessW `{:?}` in cwd `{:?}` failed: {}", + cmd_os, + cwd.as_ref().map(|c| OsString::from_wide(c)), + err + ); + bail!("{msg}"); + } + + let _main_thread = unsafe { OwnedHandle::from_raw_handle(process_info.hThread as _) }; + let proc = unsafe { OwnedHandle::from_raw_handle(process_info.hProcess as _) }; + Ok(WinChild { + proc: Mutex::new(proc), + }) + } +} + +struct ProcThreadAttributeList { + data: Vec, +} + +impl ProcThreadAttributeList { + fn with_capacity(num_attributes: DWORD) -> Result { + let mut bytes_required: usize = 0; + unsafe { + InitializeProcThreadAttributeList( + ptr::null_mut(), + num_attributes, + 0, + &mut bytes_required, + ) + }; + let mut data = vec![0; bytes_required]; + + let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _; + let res = unsafe { + InitializeProcThreadAttributeList(attr_ptr, num_attributes, 0, &mut bytes_required) + }; + ensure!( + res != 0, + "InitializeProcThreadAttributeList failed: {}", + IoError::last_os_error() + ); + Ok(Self { data }) + } + + fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.data.as_mut_slice().as_mut_ptr() as *mut _ + } + + fn set_pty(&mut self, con: HPCON) -> Result<(), Error> { + let res = unsafe { + UpdateProcThreadAttribute( + self.as_mut_ptr(), + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + con, + mem::size_of::(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + ensure!( + res != 0, + "UpdateProcThreadAttribute failed: {}", + IoError::last_os_error() + ); + Ok(()) + } +} + +impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) }; + } +} + +#[derive(Debug)] +pub struct WinChild { + proc: Mutex, +} + +impl WinChild { + fn is_complete(&mut self) -> IoResult> { + let mut status: DWORD = 0; + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + if status == STILL_ACTIVE { + Ok(None) + } else { + Ok(Some(ExitStatus::with_exit_code(status))) + } + } else { + Ok(None) + } + } + + fn do_kill(&mut self) -> IoResult<()> { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) }; + if res == 0 { + Err(IoError::last_os_error()) + } else { + Ok(()) + } + } +} + +impl ChildKiller for WinChild { + fn kill(&mut self) -> IoResult<()> { + self.do_kill().ok(); + Ok(()) + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +#[derive(Debug)] +pub struct WinChildKiller { + proc: OwnedHandle, +} + +impl ChildKiller for WinChildKiller { + fn kill(&mut self) -> IoResult<()> { + let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) }; + if res == 0 { + Err(IoError::last_os_error()) + } else { + Ok(()) + } + } + + fn clone_killer(&self) -> Box { + let proc = self.proc.try_clone().unwrap(); + Box::new(WinChildKiller { proc }) + } +} + +impl Child for WinChild { + fn try_wait(&mut self) -> IoResult> { + self.is_complete() + } + + fn wait(&mut self) -> IoResult { + if let Ok(Some(status)) = self.try_wait() { + return Ok(status); + } + let proc = self.proc.lock().unwrap().try_clone().unwrap(); + unsafe { + WaitForSingleObject(proc.as_raw_handle() as _, INFINITE); + } + let mut status: DWORD = 0; + let res = unsafe { GetExitCodeProcess(proc.as_raw_handle() as _, &mut status) }; + if res != 0 { + Ok(ExitStatus::with_exit_code(status)) + } else { + Err(IoError::last_os_error()) + } + } + + fn process_id(&self) -> Option { + let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) }; + if res == 0 { None } else { Some(res) } + } + + fn as_raw_handle(&self) -> Option { + Some(self.proc.lock().unwrap().as_raw_handle()) + } +} + +fn resolve_current_directory(cmd: &CommandBuilder) -> Option> { + let home = cmd + .get_env("USERPROFILE") + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let cwd = cmd + .get_cwd() + .and_then(|path| Path::new(path).is_dir().then(|| path.to_owned())); + let dir = cwd.or(home)?; + + let mut wide = Vec::new(); + if Path::new(&dir).is_relative() { + if let Ok(current_dir) = env::current_dir() { + wide.extend(current_dir.join(&dir).as_os_str().encode_wide()); + } else { + wide.extend(dir.encode_wide()); + } + } else { + wide.extend(dir.encode_wide()); + } + wide.push(0); + Some(wide) +} + +fn build_environment_block(cmd: &CommandBuilder) -> Vec { + let mut block = Vec::new(); + for (key, value) in cmd.iter_full_env_as_str() { + block.extend(OsStr::new(key).encode_wide()); + block.push(b'=' as u16); + block.extend(OsStr::new(value).encode_wide()); + block.push(0); + } + block.push(0); + block +} + +fn build_cmdline(cmd: &CommandBuilder) -> anyhow::Result<(Vec, Vec)> { + let exe_os: OsString = if cmd.is_default_prog() { + cmd.get_env("ComSpec") + .unwrap_or(OsStr::new("cmd.exe")) + .to_os_string() + } else { + let argv = cmd.get_argv(); + let Some(first) = argv.first() else { + bail!("missing program name"); + }; + search_path(cmd, first) + }; + + let mut cmdline = Vec::new(); + append_quoted(&exe_os, &mut cmdline); + for arg in cmd.get_argv().iter().skip(1) { + cmdline.push(' ' as u16); + ensure!( + !arg.encode_wide().any(|c| c == 0), + "invalid encoding for command line argument {arg:?}" + ); + append_quoted(arg, &mut cmdline); + } + cmdline.push(0); + + let mut exe: Vec = exe_os.encode_wide().collect(); + exe.push(0); + + Ok((exe, cmdline)) +} + +fn search_path(cmd: &CommandBuilder, exe: &OsStr) -> OsString { + if let Some(path) = cmd.get_env("PATH") { + let extensions = cmd.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE")); + for path in env::split_paths(path) { + let candidate = path.join(exe); + if candidate.exists() { + return candidate.into_os_string(); + } + + for ext in env::split_paths(extensions) { + let ext = ext.to_str().unwrap_or(""); + let path = path + .join(exe) + .with_extension(ext.strip_prefix('.').unwrap_or(ext)); + if path.exists() { + return path.into_os_string(); + } + } + } + } + + exe.to_os_string() +} + +fn append_quoted(arg: &OsStr, cmdline: &mut Vec) { + if !arg.is_empty() + && !arg.encode_wide().any(|c| { + c == ' ' as u16 + || c == '\t' as u16 + || c == '\n' as u16 + || c == '\x0b' as u16 + || c == '"' as u16 + }) + { + cmdline.extend(arg.encode_wide()); + return; + } + cmdline.push('"' as u16); + + let arg: Vec<_> = arg.encode_wide().collect(); + let mut i = 0; + while i < arg.len() { + let mut num_backslashes = 0; + while i < arg.len() && arg[i] == '\\' as u16 { + i += 1; + num_backslashes += 1; + } + + if i == arg.len() { + for _ in 0..num_backslashes * 2 { + cmdline.push('\\' as u16); + } + break; + } else if arg[i] == b'"' as u16 { + for _ in 0..num_backslashes * 2 + 1 { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } else { + for _ in 0..num_backslashes { + cmdline.push('\\' as u16); + } + cmdline.push(arg[i]); + } + i += 1; + } + cmdline.push('"' as u16); +} diff --git a/crates/tools/src/webfetch.rs b/crates/tools/src/webfetch.rs deleted file mode 100644 index 735205b..0000000 --- a/crates/tools/src/webfetch.rs +++ /dev/null @@ -1,252 +0,0 @@ -use async_trait::async_trait; -use base64::Engine; -use serde_json::json; -use tokio::time::{Duration, timeout}; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("webfetch.txt"); -const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; -const DEFAULT_TIMEOUT_MS: u64 = 30_000; -const MAX_TIMEOUT_MS: u64 = 120_000; - -pub struct WebFetchTool; - -#[async_trait] -impl Tool for WebFetchTool { - fn name(&self) -> &str { - "webfetch" - } - - fn description(&self) -> &str { - DESCRIPTION - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "url": {"type": "string"}, - "format": {"type": "string", "enum": ["text", "markdown", "html"], "default": "markdown"}, - "timeout": {"type": "number"} - }, - "required": ["url"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let url = input["url"].as_str().unwrap_or(""); - if !(url.starts_with("http://") || url.starts_with("https://")) { - return Ok(ToolOutput::error("URL must start with http:// or https://")); - } - - let format = input["format"].as_str().unwrap_or("markdown"); - let timeout_ms = input["timeout"] - .as_u64() - .unwrap_or(DEFAULT_TIMEOUT_MS / 1000) - .saturating_mul(1000) - .min(MAX_TIMEOUT_MS); - - let accept = match format { - "markdown" => { - "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" - } - "text" => "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1", - "html" => { - "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" - } - _ => "*/*", - }; - - let client = reqwest::Client::builder().user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36").build()?; - let request = client - .get(url) - .header(reqwest::header::ACCEPT, accept) - .header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9"); - - let response = timeout(Duration::from_millis(timeout_ms), request.send()).await; - let response = match response { - Ok(result) => result?, - Err(_) => return Ok(ToolOutput::error("Request timed out")), - }; - - if !response.status().is_success() { - return Ok(ToolOutput::error(format!( - "Request failed with status code: {}", - response.status() - ))); - } - - if response - .content_length() - .is_some_and(|len| len as usize > MAX_RESPONSE_SIZE) - { - return Ok(ToolOutput::error("Response too large (exceeds 5MB limit)")); - } - - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - let mime = content_type - .split(';') - .next() - .unwrap_or("") - .trim() - .to_lowercase(); - let title = format!("{url} ({content_type})"); - - let bytes = response.bytes().await?; - if bytes.len() > MAX_RESPONSE_SIZE { - return Ok(ToolOutput::error("Response too large (exceeds 5MB limit)")); - } - - if is_image_mime(&mime) { - return Ok(ToolOutput { - content: "Image fetched successfully".to_string(), - is_error: false, - metadata: Some(json!({ - "title": title, - "mime": mime, - "image_base64": base64::engine::general_purpose::STANDARD.encode(bytes), - })), - }); - } - - let content = String::from_utf8_lossy(&bytes).into_owned(); - let output = match format { - "text" => { - if content_type.contains("text/html") { - extract_text_from_html(&content) - } else { - content - } - } - "html" => content, - "markdown" => { - if content_type.contains("text/html") { - convert_html_to_markdown(&content) - } else { - content - } - } - _ => content, - }; - - Ok(ToolOutput { - content: output, - is_error: false, - metadata: Some(json!({ "title": title, "mime": mime })), - }) - } -} - -fn is_image_mime(mime: &str) -> bool { - mime.starts_with("image/") && mime != "image/svg+xml" && mime != "image/vnd.fastbidsheet" -} - -fn extract_text_from_html(html: &str) -> String { - let mut text = String::with_capacity(html.len()); - let mut in_tag = false; - let mut skip = false; - let lower = html.to_ascii_lowercase(); - let bytes = html.as_bytes(); - let lower_bytes = lower.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'<' { - if lower_bytes[i..].starts_with(b"' { - in_tag = false; - if skip - && (lower_bytes[i.saturating_sub(10)..=i] - .windows(2) - .any(|w| w == b" String { - let mut out = String::with_capacity(html.len()); - let mut in_tag = false; - for ch in html.chars() { - match ch { - '<' => in_tag = true, - '>' => in_tag = false, - _ if !in_tag => out.push(ch), - _ => {} - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ToolContext; - use devo_safety::legacy_permissions::{PermissionMode, RuleBasedPolicy}; - use serde_json::json; - use std::{path::PathBuf, sync::Arc}; - - fn default_context() -> ToolContext { - ToolContext { - cwd: PathBuf::from("."), - permissions: Arc::new(RuleBasedPolicy::new(PermissionMode::AutoApprove)), - session_id: "test".into(), - } - } - - #[test] - fn image_mime_detects_known_images() { - assert!(is_image_mime("image/png")); - assert!(is_image_mime("image/jpeg")); - assert!(!is_image_mime("image/svg+xml")); - assert!(!is_image_mime("image/vnd.fastbidsheet")); - } - - #[test] - fn extract_text_strips_scripts_and_tags() { - let html = r#"

Hi

There

"#; - assert_eq!(extract_text_from_html(html), "HiThere"); - } - - #[test] - fn convert_html_to_plaintext() { - let html = "

hello

world
"; - assert_eq!(convert_html_to_markdown(html), "hello world"); - } - - #[tokio::test] - async fn execute_rejects_invalid_url() { - let tool = WebFetchTool; - let ctx = default_context(); - let output = tool - .execute(&ctx, json!({"url": "ftp://example.com"})) - .await - .expect("execution should succeed even for invalid URL"); - assert!(output.is_error); - assert_eq!(output.content, "URL must start with http:// or https://"); - } -} diff --git a/crates/tools/src/websearch.rs b/crates/tools/src/websearch.rs deleted file mode 100644 index 5df8b4a..0000000 --- a/crates/tools/src/websearch.rs +++ /dev/null @@ -1,73 +0,0 @@ -use async_trait::async_trait; -use chrono::Datelike; -use serde_json::json; - -use crate::{Tool, ToolContext, ToolOutput}; - -const DESCRIPTION: &str = include_str!("websearch.txt"); - -pub struct WebSearchTool; - -#[async_trait] -impl Tool for WebSearchTool { - fn name(&self) -> &str { - "websearch" - } - - fn description(&self) -> &str { - static DESCRIPTION_CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); - DESCRIPTION_CACHED - .get_or_init(|| DESCRIPTION.replace("{{year}}", &chrono::Utc::now().year().to_string())) - } - - fn input_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "query": {"type": "string"}, - "numResults": {"type": "number"}, - "livecrawl": {"type": "string", "enum": ["fallback", "preferred"]}, - "type": {"type": "string", "enum": ["auto", "fast", "deep"]}, - "contextMaxCharacters": {"type": "number"} - }, - "required": ["query"] - }) - } - - async fn execute( - &self, - _ctx: &ToolContext, - input: serde_json::Value, - ) -> anyhow::Result { - let query = input["query"].as_str().unwrap_or(""); - let client = reqwest::Client::new(); - let payload = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "web_search_exa", - "arguments": { - "query": query, - "type": input["type"].as_str().unwrap_or("auto"), - "numResults": input["numResults"].as_u64().unwrap_or(8), - "livecrawl": input["livecrawl"].as_str().unwrap_or("fallback"), - "contextMaxCharacters": input["contextMaxCharacters"].as_u64() - } - } - }); - let res = client - .post("https://mcp.exa.ai/mcp") - .json(&payload) - .send() - .await?; - if !res.status().is_success() { - return Ok(ToolOutput::error(format!( - "Search error ({})", - res.status() - ))); - } - let text = res.text().await?; - Ok(ToolOutput::success(text)) - } -} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 88b14a4..8961058 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use crate::events::SavedModelEntry; +use devo_core::PermissionPreset; use devo_core::PresetModelCatalog; use devo_core::ProviderWireApi; @@ -28,6 +29,8 @@ pub struct InitialTuiSession { pub provider: ProviderWireApi, /// Initial thinking selection restored from persisted config. pub thinking_selection: Option, + /// Initial permission preset restored from project-level config. + pub permission_preset: PermissionPreset, /// Working directory used for the initial session. pub cwd: PathBuf, } diff --git a/crates/tui/src/app_command.rs b/crates/tui/src/app_command.rs index 2bad743..b5f05a6 100644 --- a/crates/tui/src/app_command.rs +++ b/crates/tui/src/app_command.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use devo_protocol::ApprovalDecisionValue; +use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; use devo_protocol::SessionId; use devo_protocol::TurnId; @@ -42,6 +44,16 @@ pub(crate) enum AppCommand { input: Vec, expected_turn_id: TurnId, }, + ApprovalRespond { + session_id: SessionId, + turn_id: TurnId, + approval_id: String, + decision: ApprovalDecisionValue, + scope: ApprovalScopeValue, + }, + UpdatePermissions { + preset: devo_protocol::PermissionPreset, + }, BrowseInputHistory { direction: InputHistoryDirection, }, @@ -77,6 +89,14 @@ pub(crate) enum AppCommandView<'a> { SteerTurn { input: &'a [InputItem], }, + ApprovalRespond { + approval_id: &'a str, + decision: &'a ApprovalDecisionValue, + scope: &'a ApprovalScopeValue, + }, + UpdatePermissions { + preset: devo_protocol::PermissionPreset, + }, OverrideTurnContext { cwd: &'a Option, model: &'a Option, @@ -193,6 +213,8 @@ impl AppCommand { Self::UserTurn { .. } => "user_turn", Self::OverrideTurnContext { .. } => "override_turn_context", Self::SteerTurn { .. } => "steer_turn", + Self::ApprovalRespond { .. } => "approval_respond", + Self::UpdatePermissions { .. } => "update_permissions", Self::BrowseInputHistory { .. } => "browse_input_history", Self::SwitchSession { .. } => "switch_session", Self::RollbackToUserTurn { .. } => "rollback_to_user_turn", @@ -236,6 +258,19 @@ impl AppCommand { approval_policy, }, Self::SteerTurn { input, .. } => AppCommandView::SteerTurn { input }, + Self::ApprovalRespond { + approval_id, + decision, + scope, + .. + } => AppCommandView::ApprovalRespond { + approval_id, + decision, + scope, + }, + Self::UpdatePermissions { preset, .. } => { + AppCommandView::UpdatePermissions { preset: *preset } + } Self::BrowseInputHistory { direction } => AppCommandView::BrowseInputHistory { direction: *direction, }, diff --git a/crates/tui/src/bottom_pane/approval_overlay.rs b/crates/tui/src/bottom_pane/approval_overlay.rs new file mode 100644 index 0000000..8f18c8e --- /dev/null +++ b/crates/tui/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,188 @@ +use crossterm::event::KeyCode; +use devo_protocol::ApprovalDecisionValue; +use devo_protocol::ApprovalScopeValue; +use devo_protocol::SessionId; +use devo_protocol::TurnId; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; + +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::list_selection_view::ListSelectionView; +use crate::bottom_pane::list_selection_view::SelectionItem; +use crate::bottom_pane::list_selection_view::SelectionViewParams; +use crate::key_hint; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ApprovalOverlayRequest { + pub(crate) session_id: SessionId, + pub(crate) turn_id: TurnId, + pub(crate) approval_id: String, + pub(crate) action_summary: String, + pub(crate) justification: String, + pub(crate) resource: Option, + pub(crate) available_scopes: Vec, + pub(crate) path: Option, + pub(crate) host: Option, + pub(crate) target: Option, +} + +pub(crate) struct ApprovalOverlay { + list: ListSelectionView, +} + +impl ApprovalOverlay { + pub(crate) fn new( + request: ApprovalOverlayRequest, + app_event_tx: AppEventSender, + accent_color: Color, + ) -> Self { + Self { + list: ListSelectionView::new(build_params(request), app_event_tx, accent_color), + } + } +} + +impl BottomPaneView for ApprovalOverlay { + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) { + self.list.handle_key_event(key_event); + } + + fn is_complete(&self) -> bool { + self.list.is_complete() + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.list.on_ctrl_c() + } +} + +impl Renderable for ApprovalOverlay { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.list.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.list.desired_height(width) + } +} + +fn build_params(request: ApprovalOverlayRequest) -> SelectionViewParams { + let header = build_header(&request); + let mut items = Vec::new(); + if request.available_scopes.is_empty() + || request + .available_scopes + .iter() + .any(|scope| scope.eq_ignore_ascii_case("once")) + { + items.push(approval_item( + "Approve once", + "Allow only this pending tool execution.", + KeyCode::Char('y'), + &request, + ApprovalDecisionValue::Approve, + ApprovalScopeValue::Once, + )); + } + if request + .available_scopes + .iter() + .any(|scope| scope.eq_ignore_ascii_case("session")) + { + items.push(approval_item( + "Approve for session", + "Allow matching requests for the rest of this session.", + KeyCode::Char('s'), + &request, + ApprovalDecisionValue::Approve, + ApprovalScopeValue::Session, + )); + } + items.push(approval_item( + "Deny", + "Reject this tool execution.", + KeyCode::Char('n'), + &request, + ApprovalDecisionValue::Deny, + ApprovalScopeValue::Once, + )); + + SelectionViewParams { + title: Some("Permission approval required".to_string()), + footer_hint: Some(Line::from( + "Use ↑/↓ to choose, Enter to confirm, Esc to cancel.", + )), + header: Box::new(header), + items, + on_cancel: Some(Box::new(move |app_event_tx| { + app_event_tx.send(AppEvent::Command(AppCommand::ApprovalRespond { + session_id: request.session_id, + turn_id: request.turn_id, + approval_id: request.approval_id.clone(), + decision: ApprovalDecisionValue::Cancel, + scope: ApprovalScopeValue::Once, + })); + })), + ..Default::default() + } +} + +fn approval_item( + name: &str, + description: &str, + shortcut: KeyCode, + request: &ApprovalOverlayRequest, + decision: ApprovalDecisionValue, + scope: ApprovalScopeValue, +) -> SelectionItem { + let session_id = request.session_id; + let turn_id = request.turn_id; + let approval_id = request.approval_id.clone(); + SelectionItem { + name: name.to_string(), + display_shortcut: Some(key_hint::plain(shortcut)), + description: Some(description.to_string()), + dismiss_on_select: true, + actions: vec![Box::new(move |app_event_tx| { + app_event_tx.send(AppEvent::Command(AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id: approval_id.clone(), + decision: decision.clone(), + scope: scope.clone(), + })); + })], + ..Default::default() + } +} + +fn build_header(request: &ApprovalOverlayRequest) -> ColumnRenderable<'static> { + let mut header = ColumnRenderable::new(); + header.push(Line::from(request.action_summary.clone()).bold()); + header.push(Line::from("")); + push_field(&mut header, "reason", Some(&request.justification)); + push_field(&mut header, "resource", request.resource.as_ref()); + push_field(&mut header, "path", request.path.as_ref()); + push_field(&mut header, "host", request.host.as_ref()); + push_field(&mut header, "target", request.target.as_ref()); + header +} + +fn push_field(header: &mut ColumnRenderable<'static>, label: &str, value: Option<&String>) { + let Some(value) = value else { + return; + }; + if value.trim().is_empty() { + return; + } + header.push(Line::from(format!("{label}: {value}")).dim()); +} diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index 9f2a98b..c456a35 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -325,8 +325,18 @@ mod tests { assert_eq!( cmds, vec![ - "theme", "model", "compact", "resume", "new", "status", "clear", "onboard", "diff", - "btw", "exit", + "theme", + "model", + "compact", + "resume", + "new", + "status", + "permissions", + "clear", + "onboard", + "diff", + "btw", + "exit", ] ); } diff --git a/crates/tui/src/bottom_pane/list_selection_view.rs b/crates/tui/src/bottom_pane/list_selection_view.rs index e2c8890..1acd76f 100644 --- a/crates/tui/src/bottom_pane/list_selection_view.rs +++ b/crates/tui/src/bottom_pane/list_selection_view.rs @@ -373,14 +373,6 @@ impl ListSelectionView { let is_selected = self.state.selected_idx == Some(visible_idx); let prefix = if is_selected { '›' } else { ' ' }; let name = item.name.as_str(); - let marker = if item.is_current { - " (current)" - } else if item.is_default { - " (default)" - } else { - "" - }; - let name_with_marker = format!("{name}{marker}"); let is_disabled = item.is_disabled || item.disabled_reason.is_some(); let n = visible_idx + 1; let wrap_prefix = if self.is_searchable { @@ -402,7 +394,7 @@ impl ListSelectionView { .or_else(|| item.description.clone()); let wrap_indent = description.is_none().then_some(wrap_prefix_width); GenericDisplayRow { - name: name_with_marker, + name: name.to_string(), name_prefix_spans, display_shortcut: item.display_shortcut, match_indices: None, diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 54d7cea..c9b136c 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -14,6 +14,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +mod approval_overlay; pub(crate) mod bottom_pane_view; mod chat_composer; mod chat_composer_history; @@ -34,6 +35,8 @@ pub(crate) mod textarea; mod theme_picker; mod unified_exec_footer; +pub(crate) use approval_overlay::ApprovalOverlay; +pub(crate) use approval_overlay::ApprovalOverlayRequest; pub(crate) use chat_composer::ChatComposer; use chat_composer::ChatComposerConfig; use chat_composer::InputResult as ComposerInputResult; diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 85154d3..9700431 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -8,13 +8,13 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; -use std::time::Duration; use std::time::Instant; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; +use devo_core::ItemId; use devo_protocol::InputItem; use devo_protocol::Model; use devo_protocol::ProviderWireApi; @@ -44,6 +44,8 @@ use devo_protocol::TurnId; use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalOverlay; +use crate::bottom_pane::ApprovalOverlayRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::InputResult; @@ -54,13 +56,13 @@ use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::SelectionItem; use crate::bottom_pane::list_selection_view::SelectionViewParams; use crate::events::SessionListEntry; +use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; use crate::exec_cell::truncated_tool_output_preview; use crate::get_git_diff::get_git_diff; use crate::history_cell; -use crate::history_cell::AI_REPLY_WRAP_WIDTH; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ScrollbackLine; @@ -82,6 +84,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) app_event_tx: AppEventSender, pub(crate) initial_session: TuiSessionState, pub(crate) initial_thinking_selection: Option, + pub(crate) initial_permission_preset: devo_protocol::PermissionPreset, pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) is_first_run: bool, @@ -214,6 +217,93 @@ struct PendingModelSelection { thinking_selection: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingApprovalRequest { + session_id: devo_protocol::SessionId, + turn_id: TurnId, + approval_id: String, + action_summary: String, +} + +struct ActiveTextItem { + item_id: ActiveTextItemId, + kind: TextItemKind, + status: DotStatus, + stream_controller: Option, + raw_text: String, + cell: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActiveTextItemId { + Server(ItemId), + Legacy(TextItemKind), +} + +impl ActiveTextItemId { + fn log_label(self) -> String { + match self { + Self::Server(item_id) => item_id.to_string(), + Self::Legacy(kind) => format!("legacy-{kind:?}"), + } + } +} + +fn permission_preset_items(current: devo_protocol::PermissionPreset) -> Vec { + [ + ( + devo_protocol::PermissionPreset::ReadOnly, + "Read Only", + "Devo can read files in the current workspace. Approval is required to edit files, run commands, or access the internet.", + ), + ( + devo_protocol::PermissionPreset::Default, + "Default", + "Devo can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files.", + ), + ( + devo_protocol::PermissionPreset::AutoReview, + "Auto-review", + "Same workspace-write permissions as Default, but eligible approvals are routed through the auto-reviewer before interrupting you.", + ), + ( + devo_protocol::PermissionPreset::FullAccess, + "Full Access", + "Devo can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.", + ), + ] + .into_iter() + .map(|(preset, label, description)| { + let name = if preset == current { + format!("{label} (current)") + } else { + label.to_string() + }; + SelectionItem { + name, + description: Some(description.to_string()), + is_current: preset == current, + dismiss_on_select: true, + actions: vec![Box::new(move |app_event_tx| { + app_event_tx.send(AppEvent::Command(AppCommand::UpdatePermissions { + preset, + })); + })], + ..Default::default() + } + }) + .collect() +} + +fn permission_preset_label(preset: devo_protocol::PermissionPreset) -> &'static str { + match preset { + devo_protocol::PermissionPreset::ReadOnly => "Read Only", + devo_protocol::PermissionPreset::Default => "Default", + devo_protocol::PermissionPreset::AutoReview => "Auto-review", + devo_protocol::PermissionPreset::FullAccess => "Full Access", + } +} + pub(crate) struct ChatWidget { // App event, such as UserTurn, List Sessions, New Session, Onboard or Browser Input History app_event_tx: AppEventSender, @@ -234,13 +324,8 @@ pub(crate) struct ChatWidget { queued_user_messages: VecDeque, external_editor_state: ExternalEditorState, status_message: String, - active_assistant_text: String, - active_reasoning_text: String, - active_assistant_cell: Option, - active_reasoning_cell: Option, - stream_controller: Option, + active_text_items: Vec, stream_chunking_policy: AdaptiveChunkingPolicy, - reasoning_stream_active: bool, available_models: Vec, saved_model_slugs: Vec, onboarding_step: Option, @@ -259,6 +344,8 @@ pub(crate) struct ChatWidget { last_query_total_tokens: usize, queued_count: usize, active_turn_id: Option, + pending_approval: Option, + permission_preset: devo_protocol::PermissionPreset, busy: bool, selection_mode: bool, selected_user_cell_index: Option, @@ -280,6 +367,7 @@ impl ChatWidget { SlashCommand::Compact => "session", SlashCommand::New => "session", SlashCommand::Resume => "session", + SlashCommand::Permissions => "permissions", SlashCommand::Diff => "diff", SlashCommand::Exit | SlashCommand::Status | SlashCommand::Clear | SlashCommand::Btw => { return; @@ -351,6 +439,18 @@ impl ChatWidget { ]) } + fn reasoning_dot_prefix(status: DotStatus) -> Line<'static> { + let color = match status { + DotStatus::Pending => Color::Rgb(210, 150, 60), + DotStatus::Completed => Color::Rgb(120, 220, 160), + DotStatus::Failed => Color::Rgb(255, 100, 100), + }; + Line::from(vec![ + Span::styled("▌", Style::default().fg(color)), + " ".into(), + ]) + } + fn truncate_display_text(value: &str, max_chars: usize) -> String { let mut rendered = String::new(); for (count, ch) in value.chars().enumerate() { @@ -597,11 +697,7 @@ impl ChatWidget { self.active_cell_revision = 0; self.active_tool_calls.clear(); self.pending_tool_calls.clear(); - self.active_assistant_text.clear(); - self.active_reasoning_text.clear(); - self.active_assistant_cell = None; - self.active_reasoning_cell = None; - self.stream_controller = None; + self.active_text_items.clear(); self.bottom_pane.clear_composer(); self.set_status_message("Resuming session"); } @@ -623,6 +719,7 @@ impl ChatWidget { app_event_tx, initial_session, initial_thinking_selection, + initial_permission_preset, initial_user_message, enhanced_keys_supported, is_first_run, @@ -695,13 +792,8 @@ impl ChatWidget { queued_user_messages, external_editor_state: ExternalEditorState::Closed, status_message: "Ready".to_string(), - active_assistant_text: String::new(), - active_reasoning_text: String::new(), - active_assistant_cell: None, - active_reasoning_cell: None, - stream_controller: None, + active_text_items: Vec::new(), stream_chunking_policy: AdaptiveChunkingPolicy::default(), - reasoning_stream_active: false, available_models, saved_model_slugs, onboarding_step: None, @@ -720,6 +812,8 @@ impl ChatWidget { last_query_total_tokens: 0, queued_count: 0, active_turn_id: None, + pending_approval: None, + permission_preset: initial_permission_preset, busy: false, selection_mode: false, selected_user_cell_index: None, @@ -770,7 +864,7 @@ impl ChatWidget { self.session.model.as_ref().map(|m| m.slug.clone()), self.thinking_selection.clone(), /*sandbox*/ None, - /*approval_policy*/ None, + Some("on-request".to_string()), ))); self.set_status_message("Message queued"); } else { @@ -1022,9 +1116,7 @@ impl ChatWidget { pub(crate) fn pre_draw_tick(&mut self) { self.advance_startup_header_animation(); - if self.stream_controller.is_some() { - self.run_stream_commit_tick(); - } + self.run_stream_commit_tick(); self.bottom_pane.pre_draw_tick(); } @@ -1065,10 +1157,15 @@ impl ChatWidget { } self.set_status_message(format!("Command queued: {}", command.kind())); } + AppEvent::RunSlashCommand { command } => { + if let Ok(command) = command.parse::() { + self.handle_slash_command(command, String::new()); + } + self.frame_requester.schedule_frame(); + } AppEvent::Exit(_) | AppEvent::OpenSlashCommandPopup | AppEvent::ClosePopup - | AppEvent::RunSlashCommand { .. } | AppEvent::OpenModelPicker | AppEvent::OpenThinkingPicker | AppEvent::OpenThemePicker @@ -1110,47 +1207,83 @@ impl ChatWidget { self.session.reasoning_effort = reasoning_effort; self.refresh_header_box(); self.busy = true; - self.active_assistant_text.clear(); - self.active_reasoning_text.clear(); - self.active_assistant_cell = None; - self.active_reasoning_cell = None; - self.stream_controller = Some(StreamController::new(None, &self.session.cwd)); + self.active_text_items.clear(); self.stream_chunking_policy.reset(); - self.reasoning_stream_active = false; self.bottom_pane.set_task_running(true); } + WorkerEvent::TextItemStarted { item_id, kind } => { + self.start_text_item(ActiveTextItemId::Server(item_id), kind); + self.set_status_message(match kind { + TextItemKind::Assistant => "Generating", + TextItemKind::Reasoning => "Thinking", + }); + } + WorkerEvent::TextItemDelta { + item_id, + kind, + delta, + } => { + self.push_text_item_delta(ActiveTextItemId::Server(item_id), kind, &delta); + self.set_status_message(match kind { + TextItemKind::Assistant => "Generating", + TextItemKind::Reasoning => "Thinking", + }); + } + WorkerEvent::TextItemCompleted { + item_id, + kind, + final_text, + } => { + self.complete_text_item(ActiveTextItemId::Server(item_id), kind, final_text); + self.set_status_message(match kind { + TextItemKind::Assistant => "Generating", + TextItemKind::Reasoning => "Thinking", + }); + } WorkerEvent::TextDelta(text) => { - self.push_assistant_stream_delta(&text); + if !self.has_server_active_item(TextItemKind::Assistant) { + self.push_text_item_delta( + ActiveTextItemId::Legacy(TextItemKind::Assistant), + TextItemKind::Assistant, + &text, + ); + } self.set_status_message("Generating"); } WorkerEvent::ReasoningDelta(text) => { - self.reasoning_stream_active = true; - self.active_reasoning_text.push_str(&text); - self.sync_active_reasoning_cell(); + if !self.has_server_active_item(TextItemKind::Reasoning) { + self.push_text_item_delta( + ActiveTextItemId::Legacy(TextItemKind::Reasoning), + TextItemKind::Reasoning, + &text, + ); + } self.set_status_message("Thinking"); } WorkerEvent::AssistantMessageCompleted(text) => { - if self.busy && self.stream_controller.is_none() { - self.active_assistant_text = text; + if !self.has_server_active_item(TextItemKind::Assistant) { + self.complete_text_item( + ActiveTextItemId::Legacy(TextItemKind::Assistant), + TextItemKind::Assistant, + text, + ); } - self.sync_active_assistant_cell(); self.set_status_message("Generating"); } WorkerEvent::ReasoningCompleted(text) => { - if self.busy { - self.active_reasoning_text = text; + if !self.has_server_active_item(TextItemKind::Reasoning) { + self.complete_text_item( + ActiveTextItemId::Legacy(TextItemKind::Reasoning), + TextItemKind::Reasoning, + text, + ); } - self.sync_active_reasoning_cell(); - self.reasoning_stream_active = false; - self.flush_stream_commit_ticks(); self.set_status_message("Thinking"); } WorkerEvent::ToolCall { tool_use_id, summary, } => { - // Do not commit active streams here — pending tool calls share the - // active viewport alongside reasoning/assistant text. let title = summary; let tool_call = ActiveToolCall { tool_use_id: tool_use_id.clone(), @@ -1159,26 +1292,21 @@ impl ChatWidget { }; self.active_tool_calls .insert(tool_use_id.clone(), tool_call.clone()); - self.pending_tool_calls.push(tool_call); + self.add_history_entry_without_redraw(Box::new( + history_cell::AgentMessageCell::new_with_prefix( + tool_call.lines, + self.dot_prefix(DotStatus::Pending), + " ", + false, + ), + )); self.frame_requester.schedule_frame(); self.set_status_message("Tool started"); } WorkerEvent::ToolOutputDelta { tool_use_id, delta } => { - // Append streaming output to the active tool call lines if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) { let line = Line::from(delta.clone()).patch_style(Self::tool_text_style()); tool_call.lines.push(line); - // Also update the pending viewport entry - if let Some(pending) = self - .pending_tool_calls - .iter_mut() - .find(|tc| tc.tool_use_id == tool_use_id) - { - pending - .lines - .push(Line::from(delta).patch_style(Self::tool_text_style())); - } - self.frame_requester.schedule_frame(); } } WorkerEvent::ToolResult { @@ -1188,7 +1316,6 @@ impl ChatWidget { is_error, truncated, } => { - self.commit_active_streams(DotStatus::Completed); // Remove from pending viewport entries — it will be committed to history below. if let Some(pos) = self .pending_tool_calls @@ -1232,7 +1359,59 @@ impl ChatWidget { "Tool completed" }); } - // TODO: The token usage should include `total_input_cahed_tokens` or `total_read_cached_tokens` + WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id, + action_summary, + justification, + resource, + available_scopes, + path, + host, + target, + } => { + self.commit_active_streams(DotStatus::Completed); + self.pending_approval = Some(PendingApprovalRequest { + session_id, + turn_id, + approval_id: approval_id.clone(), + action_summary: action_summary.clone(), + }); + self.bottom_pane + .open_popup_view(Box::new(ApprovalOverlay::new( + ApprovalOverlayRequest { + session_id, + turn_id, + approval_id, + action_summary, + justification, + resource, + available_scopes, + path, + host, + target, + }, + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.busy = true; + self.bottom_pane.set_task_running(false); + self.set_status_message("Approval required"); + } + WorkerEvent::ApprovalDecision { + approval_id: _, + decision, + scope, + } => { + self.pending_approval = None; + let symbol = if decision == "approve" { "✔" } else { "✗" }; + self.add_to_history(history_cell::new_info_event( + format!("{symbol} Permission request {decision} ({scope})"), + None, + )); + self.bottom_pane.set_task_running(self.busy); + } WorkerEvent::UsageUpdated { total_input_tokens, total_output_tokens, @@ -1261,6 +1440,7 @@ impl ChatWidget { self.commit_active_streams(DotStatus::Completed); self.active_tool_calls.clear(); self.pending_tool_calls.clear(); + self.pending_approval = None; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1305,6 +1485,7 @@ impl ChatWidget { self.commit_active_streams(DotStatus::Failed); self.active_tool_calls.clear(); self.pending_tool_calls.clear(); + self.pending_approval = None; self.busy = false; self.turn_count = turn_count; self.total_input_tokens = total_input_tokens; @@ -1373,13 +1554,8 @@ impl ChatWidget { self.update_session_request_model(model); self.thinking_selection = thinking; self.session.reasoning_effort = reasoning_effort; - self.active_assistant_text.clear(); - self.active_reasoning_text.clear(); - self.active_assistant_cell = None; - self.active_reasoning_cell = None; - self.stream_controller = None; + self.active_text_items.clear(); self.stream_chunking_policy.reset(); - self.reasoning_stream_active = false; self.history.clear(); self.next_history_flush_index = 0; self.busy = false; @@ -1418,13 +1594,8 @@ impl ChatWidget { self.session.reasoning_effort = reasoning_effort; self.history.clear(); self.next_history_flush_index = 0; - self.active_assistant_text.clear(); - self.active_reasoning_text.clear(); - self.active_assistant_cell = None; - self.active_reasoning_cell = None; - self.stream_controller = None; + self.active_text_items.clear(); self.stream_chunking_policy.reset(); - self.reasoning_stream_active = false; self.total_input_tokens = total_input_tokens; self.total_output_tokens = total_output_tokens; self.total_cache_read_tokens = total_cache_read_tokens; @@ -1539,7 +1710,7 @@ impl ChatWidget { self.session.model.as_ref().map(|model| model.slug.clone()), self.thinking_selection.clone(), /*sandbox*/ None, - /*approval_policy*/ None, + Some("on-request".to_string()), ))); self.set_status_message("Submitted locally"); } @@ -1558,13 +1729,8 @@ impl ChatWidget { SlashCommand::Clear => { self.history.clear(); self.next_history_flush_index = 0; - self.active_assistant_text.clear(); - self.active_reasoning_text.clear(); - self.active_assistant_cell = None; - self.active_reasoning_cell = None; - self.stream_controller = None; + self.active_text_items.clear(); self.stream_chunking_policy.reset(); - self.reasoning_stream_active = false; self.set_status_message("Transcript cleared"); } SlashCommand::Onboard => { @@ -1596,6 +1762,9 @@ impl ChatWidget { self.add_to_history(PlainHistoryCell::new(lines)); self.set_status_message("Session status shown"); } + SlashCommand::Permissions => { + self.open_permissions_picker(); + } SlashCommand::Theme => { self.open_theme_picker(); } @@ -1794,12 +1963,8 @@ impl ChatWidget { body: &str, status: DotStatus, ) { - let markdown_width = if title == "Assistant" || title == "Reasoning" { - None - } else { - Some(AI_REPLY_WRAP_WIDTH) - }; - let mut lines = if title == "Assistant" || title == "Reasoning" { + let is_ai_message = title == "Assistant" || title == "Reasoning"; + let mut lines = if is_ai_message { Vec::new() } else { vec![Line::from(title.to_string()).bold()] @@ -1808,7 +1973,7 @@ impl ChatWidget { let mut body_lines = Vec::new(); append_markdown( body, - markdown_width, + /*width*/ None, Some(&self.session.cwd), &mut body_lines, ); @@ -1821,9 +1986,9 @@ impl ChatWidget { } lines.extend(body_lines); } else { - append_markdown(body, markdown_width, Some(&self.session.cwd), &mut lines); + append_markdown(body, None, Some(&self.session.cwd), &mut lines); } - if title == "Assistant" || title == "Reasoning" { + if is_ai_message { self.add_history_entry_without_redraw(Box::new( history_cell::AgentMessageCell::new_ai_response_with_prefix( lines, @@ -1897,8 +2062,8 @@ impl ChatWidget { TranscriptItemKind::ToolCall => { self.add_history_entry_without_redraw(Box::new( history_cell::AgentMessageCell::new_with_prefix( - vec![Self::ran_tool_line(&item.title)], - Self::tool_dot_prefix(), + vec![Self::running_tool_line(&item.title)], + self.dot_prefix(DotStatus::Pending), " ", false, ), @@ -1919,6 +2084,7 @@ impl ChatWidget { TranscriptItemKind::Error => self.add_history_entry_without_redraw(Box::new( history_cell::new_error_event_with_hint(item.body, Some(item.title)), )), + TranscriptItemKind::Approval => {} TranscriptItemKind::System => { self.add_history_entry_without_redraw(Box::new(history_cell::new_info_event( item.title, @@ -1939,157 +2105,339 @@ impl ChatWidget { } fn commit_active_streams(&mut self, status: DotStatus) { - // Take the text first so any buffered delta events that arrive after - // this call will not re-create the active reasoning/assistant cells - // with stale content. - let reasoning_text = std::mem::take(&mut self.active_reasoning_text); - self.active_reasoning_cell = None; - self.active_assistant_cell = None; - self.reasoning_stream_active = false; - if !reasoning_text.trim().is_empty() { - self.add_markdown_history_with_status("Reasoning", &reasoning_text, status); - } - if let Some(controller) = self.stream_controller.as_mut() { - if let Some(cell) = controller.finalize() { - self.add_history_entry_without_redraw(cell); - } - self.stream_controller = None; - self.stream_chunking_policy.reset(); - } else { - let assistant_text = std::mem::take(&mut self.active_assistant_text); - if !assistant_text.trim().is_empty() { - self.add_markdown_history_with_status_without_redraw( - "Assistant", - &assistant_text, - status, - ); - } + tracing::debug!( + status = ?status, + active_items = ?self.active_text_item_log_order(), + "committing all active text items" + ); + while !self.active_text_items.is_empty() { + self.commit_text_item_at(0, status); } } - fn push_assistant_stream_delta(&mut self, text: &str) { - if self.stream_controller.is_none() { - self.stream_controller = Some(StreamController::new(None, &self.session.cwd)); - self.stream_chunking_policy.reset(); + fn start_text_item(&mut self, item_id: ActiveTextItemId, kind: TextItemKind) { + if self + .active_text_items + .iter() + .any(|item| item.item_id == item_id) + { + return; } - if let Some(controller) = self.stream_controller.as_mut() { - controller.push(text); + + let stream_controller = match kind { + TextItemKind::Assistant => Some(StreamController::new(None, &self.session.cwd)), + TextItemKind::Reasoning => None, + }; + let insert_index = self.active_text_item_insert_index(kind); + tracing::debug!( + item_id = %item_id.log_label(), + kind = ?kind, + insert_index, + before = ?self.active_text_item_log_order(), + "starting active text item" + ); + self.active_text_items.insert( + insert_index, + ActiveTextItem { + item_id, + kind, + status: DotStatus::Pending, + stream_controller, + raw_text: String::new(), + cell: None, + }, + ); + tracing::trace!( + after = ?self.active_text_item_log_order(), + "active text item order after start" + ); + self.stream_chunking_policy.reset(); + } + + fn push_text_item_delta(&mut self, item_id: ActiveTextItemId, kind: TextItemKind, delta: &str) { + let index = self.ensure_text_item(item_id, kind); + tracing::debug!( + item_id = %item_id.log_label(), + kind = ?kind, + delta_len = delta.len(), + active_items = ?self.active_text_item_log_order(), + "received active text item delta" + ); + match kind { + TextItemKind::Assistant => { + if let Some(controller) = self.active_text_items[index].stream_controller.as_mut() { + controller.push(delta); + } + } + TextItemKind::Reasoning => { + self.active_text_items[index].raw_text.push_str(delta); + } } - self.sync_active_assistant_cell(); + self.sync_text_item_cell(index); self.frame_requester.schedule_frame(); } - fn flush_assistant_stream_commits(&mut self) { - self.sync_active_assistant_cell(); + fn complete_text_item( + &mut self, + item_id: ActiveTextItemId, + kind: TextItemKind, + final_text: String, + ) { + let index = self.ensure_text_item(item_id, kind); + tracing::debug!( + item_id = %item_id.log_label(), + kind = ?kind, + final_text_len = final_text.len(), + active_items = ?self.active_text_item_log_order(), + "completed active text item" + ); + self.active_text_items[index].status = DotStatus::Completed; + if !final_text.trim().is_empty() { + self.active_text_items[index].raw_text = final_text; + } + self.sync_text_item_cell(index); + self.commit_completed_text_items(); } - fn finalize_assistant_stream(&mut self) { - self.stream_controller = None; - self.sync_active_assistant_cell(); + fn ensure_text_item(&mut self, item_id: ActiveTextItemId, kind: TextItemKind) -> usize { + if let Some(index) = self + .active_text_items + .iter() + .position(|item| item.item_id == item_id) + { + return index; + } + + self.start_text_item(item_id, kind); + self.active_text_items + .iter() + .position(|item| item.item_id == item_id) + .unwrap_or_else(|| self.active_text_items.len().saturating_sub(1)) } - fn sync_active_assistant_cell(&mut self) { - if let Some(controller) = &self.stream_controller { - let lines = controller.live_lines(); - if lines.iter().any(|line| !Self::is_blank_line(line)) { - self.active_assistant_cell = - Some(history_cell::AgentMessageCell::new_ai_response_with_prefix( - lines, - Self::pending_dot_prefix(), - " ", - false, - )); - } else { - self.active_assistant_cell = None; + fn has_server_active_item(&self, kind: TextItemKind) -> bool { + self.active_text_items + .iter() + .any(|item| matches!(item.item_id, ActiveTextItemId::Server(_)) && item.kind == kind) + } + + fn commit_text_item_at(&mut self, index: usize, status: DotStatus) { + if index >= self.active_text_items.len() { + return; + } + + let mut item = self.active_text_items.remove(index); + tracing::debug!( + item_id = %item.item_id.log_label(), + kind = ?item.kind, + status = ?status, + remaining = ?self.active_text_item_log_order(), + "committing active text item" + ); + match item.kind { + TextItemKind::Assistant => { + if let Some(controller) = item.stream_controller.as_mut() { + let (_cell, source) = controller.finalize(); + if let Some(source) = source { + self.add_assistant_markdown_source(source, status); + } else if !item.raw_text.trim().is_empty() { + self.add_markdown_history_with_status_without_redraw( + "Assistant", + &item.raw_text, + status, + ); + } + } else if !item.raw_text.trim().is_empty() { + self.add_markdown_history_with_status_without_redraw( + "Assistant", + &item.raw_text, + status, + ); + } + } + TextItemKind::Reasoning => { + if !item.raw_text.trim().is_empty() { + self.add_markdown_history_with_status("Reasoning", &item.raw_text, status); + } } - } else if !self.active_assistant_text.trim().is_empty() { - self.active_assistant_cell = - Some(self.bulleted_markdown_cell( - &self.active_assistant_text, - Self::pending_dot_prefix(), - )); - } else { - self.active_assistant_cell = None; } + self.stream_chunking_policy.reset(); } - fn run_stream_commit_tick(&mut self) { - if self.reasoning_stream_active { + fn add_assistant_markdown_source(&mut self, source: String, status: DotStatus) { + if source.trim().is_empty() { return; } - self.drain_stream_commit_tick(true); + + self.add_history_entry_without_redraw(Box::new(history_cell::AgentMarkdownCell::new( + source, + &self.session.cwd, + self.dot_prefix(status), + " ", + ))); } - fn flush_stream_commit_ticks(&mut self) { - if self.reasoning_stream_active { - return; + fn active_text_item_insert_index(&self, kind: TextItemKind) -> usize { + match kind { + TextItemKind::Reasoning => self + .active_text_items + .iter() + .position(|item| item.kind == TextItemKind::Assistant) + .unwrap_or(self.active_text_items.len()), + TextItemKind::Assistant => self.active_text_items.len(), } - if let Some(controller) = self.stream_controller.as_mut() { - let queue_len = controller.queued_lines(); - if queue_len == 0 { - return; + } + + fn commit_completed_text_items(&mut self) { + let mut index = 0; + while index < self.active_text_items.len() { + let item = &self.active_text_items[index]; + if item.status != DotStatus::Completed { + index += 1; + continue; } - // Drain all queued lines in one batch so they become a single - // history cell rather than multiple cells separated by blank rows. - let (cell, _idle) = controller.on_commit_tick_batch(queue_len); - if let Some(cell) = cell { - self.add_history_entry_without_redraw(cell); - self.sync_active_assistant_cell(); - self.frame_requester.schedule_frame(); + + if item.kind == TextItemKind::Assistant + && self.active_text_items[..index] + .iter() + .any(|prior| prior.kind == TextItemKind::Reasoning) + { + tracing::debug!( + item_id = %item.item_id.log_label(), + active_items = ?self.active_text_item_log_order(), + "deferring assistant commit until prior reasoning item commits" + ); + index += 1; + continue; } + + self.commit_text_item_at(index, DotStatus::Completed); } } - fn drain_stream_commit_tick(&mut self, schedule_followup: bool) { + fn active_text_item_log_order(&self) -> Vec { + self.active_text_items + .iter() + .map(|item| { + format!( + "{:?}:{}:{:?}", + item.kind, + item.item_id.log_label(), + item.status + ) + }) + .collect() + } + + fn run_stream_commit_tick(&mut self) { let now = Instant::now(); - let output = run_commit_tick( - &mut self.stream_chunking_policy, - self.stream_controller.as_mut(), - CommitTickScope::AnyMode, - now, - ); + let mut output_cells = Vec::new(); + let mut needs_followup = false; + let mut changed_indexes = Vec::new(); - if !output.cells.is_empty() { - for cell in output.cells { - self.add_history_entry_without_redraw(cell); + for (index, item) in self.active_text_items.iter_mut().enumerate() { + let Some(controller) = item.stream_controller.as_mut() else { + continue; + }; + let output = run_commit_tick( + &mut self.stream_chunking_policy, + Some(controller), + CommitTickScope::AnyMode, + now, + ); + if item.kind == TextItemKind::Assistant { + if !output.cells.is_empty() { + changed_indexes.push(index); + } + if !output.all_idle { + needs_followup = true; + } + continue; + } + if !output.cells.is_empty() { + output_cells.extend(output.cells); + changed_indexes.push(index); + } + if !output.all_idle { + needs_followup = true; } - self.sync_active_assistant_cell(); - self.frame_requester.schedule_frame(); } - if schedule_followup && self.stream_controller.is_some() && !output.all_idle { + for cell in output_cells { + self.add_history_entry_without_redraw(cell); + } + for index in changed_indexes { + self.sync_text_item_cell(index); + } + if needs_followup { self.frame_requester - .schedule_frame_in(Duration::from_millis(16)); + .schedule_frame_in(std::time::Duration::from_millis(16)); + } + if !self.active_text_items.is_empty() { + self.frame_requester.schedule_frame(); } } - fn sync_active_reasoning_cell(&mut self) { - if !self.active_reasoning_text.trim().is_empty() { - let mut body_lines = Vec::new(); - append_markdown( - &self.active_reasoning_text, - None, - Some(&self.session.cwd), - &mut body_lines, - ); - Self::patch_lines_style(&mut body_lines, Self::reasoning_text_style()); - if let Some(first_line) = body_lines.first_mut() { - first_line.spans.insert( - 0, - Span::styled("Thinking: ", Self::reasoning_heading_style()), - ); - } - let lines = body_lines; - self.active_reasoning_cell = - Some(history_cell::AgentMessageCell::new_ai_response_with_prefix( + fn sync_text_item_cell(&mut self, index: usize) { + if index >= self.active_text_items.len() { + return; + } + + let cell = match self.active_text_items[index].kind { + TextItemKind::Assistant => self.assistant_active_cell(&self.active_text_items[index]), + TextItemKind::Reasoning => self.reasoning_active_cell(&self.active_text_items[index]), + }; + self.active_text_items[index].cell = cell; + } + + fn assistant_active_cell( + &self, + item: &ActiveTextItem, + ) -> Option { + if let Some(controller) = &item.stream_controller { + let lines = controller.live_lines(); + if lines.iter().any(|line| !Self::is_blank_line(line)) { + return Some(history_cell::AgentMessageCell::new_ai_response_with_prefix( lines, Self::pending_dot_prefix(), " ", false, )); - } else { - self.active_reasoning_cell = None; + } + } else if !item.raw_text.trim().is_empty() { + return Some(self.bulleted_markdown_cell(&item.raw_text, Self::pending_dot_prefix())); } + None + } + + fn reasoning_active_cell( + &self, + item: &ActiveTextItem, + ) -> Option { + if item.raw_text.trim().is_empty() { + return None; + } + + let mut body_lines = Vec::new(); + append_markdown( + &item.raw_text, + None, + Some(&self.session.cwd), + &mut body_lines, + ); + Self::patch_lines_style(&mut body_lines, Self::reasoning_text_style()); + if let Some(first_line) = body_lines.first_mut() { + first_line.spans.insert( + 0, + Span::styled("Thinking: ", Self::reasoning_heading_style()), + ); + } + Some(history_cell::AgentMessageCell::new_ai_response_with_prefix( + body_lines, + Self::reasoning_dot_prefix(item.status), + " ", + false, + )) } fn last_known_width(&self) -> u16 { @@ -2181,7 +2529,9 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn has_stream_controller(&self) -> bool { - self.stream_controller.is_some() + self.active_text_items + .iter() + .any(|item| item.stream_controller.is_some()) } #[cfg(test)] @@ -2419,13 +2769,16 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + pub(crate) fn active_viewport_lines_for_test(&self, width: u16) -> Vec> { + self.active_viewport_lines(width) + } + fn active_viewport_lines(&self, width: u16) -> Vec> { let mut lines = Vec::new(); - if let Some(active_cell) = &self.active_cell { - Self::extend_lines_with_separator(&mut lines, active_cell.display_lines(width)); - } - if let Some(reasoning_cell) = &self.active_reasoning_cell { - Self::extend_lines_with_separator(&mut lines, reasoning_cell.display_lines(width)); + for item in &self.active_text_items { + if let Some(cell) = &item.cell { + Self::extend_lines_with_separator(&mut lines, cell.display_lines(width)); + } } // Pending tool calls are shown with a pending (cyan) dot until their results arrive. for pending in &self.pending_tool_calls { @@ -2440,9 +2793,6 @@ impl ChatWidget { .display_lines(width), ); } - if let Some(assistant_cell) = &self.active_assistant_cell { - Self::extend_lines_with_separator(&mut lines, assistant_cell.display_lines(width)); - } Self::trim_trailing_blank_lines(&mut lines); lines } @@ -2470,7 +2820,6 @@ impl ChatWidget { .skip(self.next_history_flush_index) .enumerate() { - let wrap_policy = cell.scrollback_wrap_policy(); let cell_lines = cell.display_lines(width); let should_insert_separator = index > 0 && !cell_lines.is_empty() @@ -2482,20 +2831,13 @@ impl ChatWidget { .first() .is_some_and(|line| !Self::is_blank_line(line)); if should_insert_separator { - lines.push(ScrollbackLine::new(Line::from(""), wrap_policy)); + lines.push(ScrollbackLine::new(Line::from(""))); } - lines.extend( - cell_lines - .into_iter() - .map(|line| ScrollbackLine::new(line, wrap_policy)), - ); + lines.extend(cell_lines.into_iter().map(ScrollbackLine::new)); } self.next_history_flush_index = self.history.len(); if !lines.is_empty() { - lines.push(ScrollbackLine::new( - Line::from(""), - history_cell::ScrollbackWrapPolicy::NoAdditionalWrapLimit, - )); + lines.push(ScrollbackLine::new(Line::from(""))); } lines } @@ -2562,6 +2904,32 @@ impl ChatWidget { self.set_status_message("Select a theme"); } + fn open_permissions_picker(&mut self) { + let current = self.permission_preset; + self.bottom_pane + .open_popup_view(Box::new(ListSelectionView::new( + SelectionViewParams { + title: Some("Update Model Permissions".to_string()), + footer_hint: Some(Line::from("Press enter to confirm or esc to go back")), + items: permission_preset_items(current), + ..SelectionViewParams::default() + }, + self.app_event_tx.clone(), + self.active_accent_color(), + ))); + self.set_status_message("Select permissions"); + } + + pub(crate) fn note_permissions_updated(&mut self, preset: devo_protocol::PermissionPreset) { + self.permission_preset = preset; + let label = permission_preset_label(preset); + self.add_to_history(history_cell::new_info_event( + format!("Permissions updated to {label}"), + None, + )); + self.set_status_message(format!("Permissions updated to {label}")); + } + fn apply_theme_selection(&mut self, name: String) { if let Some(theme) = self.theme_set.find(&name).cloned() { self.active_theme_name = name.clone(); @@ -2834,9 +3202,7 @@ impl Renderable for ChatWidget { let viewport_lines = self.active_viewport_lines(history_area.width); if !viewport_lines.is_empty() { - Paragraph::new(Text::from(viewport_lines)) - .wrap(Wrap { trim: false }) - .render(history_area, buf); + Paragraph::new(Text::from(viewport_lines)).render(history_area, buf); } self.bottom_pane.render(bottom_area, buf); diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 2daadd7..4bf7808 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -4,10 +4,16 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; +use devo_protocol::ApprovalDecisionValue; +use devo_protocol::ApprovalScopeValue; use devo_protocol::InputItem; +use devo_protocol::ItemId; use devo_protocol::Model; +use devo_protocol::PermissionPreset; use devo_protocol::ReasoningEffort; +use devo_protocol::SessionId; use devo_protocol::ThinkingCapability; +use devo_protocol::TurnId; use pretty_assertions::assert_eq; use tokio::sync::mpsc; @@ -40,6 +46,7 @@ fn widget_with_model_and_thinking( app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(model)), initial_thinking_selection, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -62,6 +69,7 @@ fn onboarding_widget_with_model( app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(model)), initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -102,6 +110,19 @@ fn find_row_index(rows: &[String], needle: &str) -> Option { rows.iter().position(|row| row.contains(needle)) } +fn scrollback_plain_lines(lines: &[crate::history_cell::ScrollbackLine]) -> Vec { + lines + .iter() + .map(|line| { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} + fn trim_trailing_blank_scrollback_lines( mut lines: Vec, ) -> Vec { @@ -116,6 +137,18 @@ fn trim_trailing_blank_scrollback_lines( lines } +fn indices_containing(lines: &[String], needles: &[&str]) -> Vec { + needles + .iter() + .map(|needle| { + lines + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("missing {needle} in:\n{}", lines.join("\n"))) + }) + .collect() +} + #[test] fn resume_command_opens_loading_browser_immediately() { let model = Model { @@ -138,6 +171,191 @@ fn resume_command_opens_loading_browser_immediately() { ); } +#[test] +fn approval_request_renders_bottom_pane_menu_and_accepts_once() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: "approval-call-1".to_string(), + action_summary: "write src/main.rs".to_string(), + justification: "Tool execution requires approval.".to_string(), + resource: Some("FileWrite".to_string()), + available_scopes: vec!["once".to_string(), "session".to_string()], + path: Some("src/main.rs".to_string()), + host: None, + target: None, + }); + + let scrollback = widget.drain_scrollback_lines(80); + assert!(!scrollback_contains_text( + &scrollback, + "Permission required" + )); + + let rendered = rendered_rows(&widget, 80, 16).join("\n"); + assert!(rendered.contains("Permission approval required")); + assert!(rendered.contains("Approve once")); + assert!(rendered.contains("Approve for session")); + assert!(rendered.contains("Deny")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let event = app_event_rx.try_recv().expect("approval response event"); + assert_eq!( + event, + AppEvent::Command(AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id: "approval-call-1".to_string(), + decision: ApprovalDecisionValue::Approve, + scope: ApprovalScopeValue::Once, + }) + ); +} + +#[test] +fn approval_request_bottom_pane_menu_denies_with_n_shortcut() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::ApprovalRequest { + session_id, + turn_id, + approval_id: "approval-call-2".to_string(), + action_summary: "run shell command".to_string(), + justification: "Tool execution requires approval.".to_string(), + resource: Some("ShellExec".to_string()), + available_scopes: vec!["once".to_string()], + path: None, + host: None, + target: Some("cargo test".to_string()), + }); + + let rendered = rendered_rows(&widget, 80, 16).join("\n"); + assert!(rendered.contains("Permission approval required")); + assert!(rendered.contains("run shell command")); + assert!(rendered.contains("Deny")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + + let event = app_event_rx.try_recv().expect("approval response event"); + assert_eq!( + event, + AppEvent::Command(AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id: "approval-call-2".to_string(), + decision: ApprovalDecisionValue::Deny, + scope: ApprovalScopeValue::Once, + }) + ); +} + +#[test] +fn submitted_prompt_requests_on_request_approval_policy() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.submit_text("please edit a file".to_string()); + + let event = app_event_rx.try_recv().expect("user turn event"); + let AppEvent::Command(AppCommand::UserTurn { + approval_policy, .. + }) = event + else { + panic!("expected user turn command"); + }; + assert_eq!(approval_policy, Some("on-request".to_string())); +} + +#[test] +fn permissions_command_opens_bottom_pane_picker_and_updates_default() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: TurnId::new(), + }); + + widget.handle_app_event(AppEvent::RunSlashCommand { + command: "permissions".to_string(), + }); + + let rendered = rendered_rows(&widget, 100, 18).join("\n"); + assert!(rendered.contains("Update Model Permissions")); + assert!(rendered.contains("Read Only")); + assert!(rendered.contains("Default (current)")); + assert!(rendered.contains("Auto-review")); + assert!(rendered.contains("Full Access")); + + widget.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + + let event = app_event_rx.try_recv().expect("permissions update event"); + assert_eq!( + event, + AppEvent::Command(AppCommand::UpdatePermissions { + preset: devo_protocol::PermissionPreset::ReadOnly, + }) + ); +} + +#[test] +fn permissions_command_marks_initial_project_preset_current() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (app_event_tx, _app_event_rx) = mpsc::unbounded_channel(); + let mut widget = ChatWidget::new_with_app_event(ChatWidgetInit { + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(app_event_tx), + initial_session: TuiSessionState::new(PathBuf::from("."), Some(model)), + initial_thinking_selection: None, + initial_permission_preset: PermissionPreset::FullAccess, + initial_user_message: None, + enhanced_keys_supported: true, + is_first_run: false, + available_models: Vec::new(), + saved_model_slugs: Vec::new(), + show_model_onboarding: false, + startup_tooltip_override: None, + initial_theme_name: None, + }); + + widget.handle_app_event(AppEvent::RunSlashCommand { + command: "permissions".to_string(), + }); + + let rendered = rendered_rows(&widget, 100, 18).join("\n"); + assert!(rendered.contains("Full Access (current)")); +} + #[test] fn thinking_entries_are_generated_from_model_capability_options() { let model = Model { @@ -332,7 +550,7 @@ fn submit_text_emits_user_turn_with_model_and_thinking() { model: Some("test-model".to_string()), thinking: Some("disabled".to_string()), sandbox: None, - approval_policy: None, + approval_policy: Some("on-request".to_string()), }) ); } @@ -365,7 +583,7 @@ fn typed_character_submits_after_paste_burst_flush() { model: Some("test-model".to_string()), thinking: None, sandbox: None, - approval_policy: None, + approval_policy: Some("on-request".to_string()), }) ); } @@ -414,7 +632,7 @@ fn key_release_does_not_duplicate_text_input() { model: Some("test-model".to_string()), thinking: None, sandbox: None, - approval_policy: None, + approval_policy: Some("on-request".to_string()), }) ); } @@ -686,9 +904,9 @@ fn session_switch_restores_header_and_double_blank_line_before_user_input() { assert!( committed_rows .windows(3) - .any(|window| window[0].trim_end() == "┃" + .any(|window| window[0].trim_end() == "▌" && window[1].contains("hello") - && window[2].trim_end() == "┃"), + && window[2].trim_end() == "▌"), "expected blank line spacing with colored bar before restored user input: {committed_lines:?}" ); } @@ -844,6 +1062,87 @@ fn active_assistant_markdown_does_not_double_wrap() { ); } +#[test] +fn active_assistant_multiline_text_has_no_extra_blank_rows() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Line1\nLine2\nLine3\n".to_string(), + )); + + let rows = rendered_rows(&widget, 80, 12); + let line1 = find_row_index(&rows, "Line1").expect("missing Line1"); + let line2 = find_row_index(&rows, "Line2").expect("missing Line2"); + let line3 = find_row_index(&rows, "Line3").expect("missing Line3"); + assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); + assert_eq!(line3, line2 + 1, "unexpected rows:\n{}", rows.join("\n")); +} + +#[test] +fn active_assistant_renders_resume_like_markdown_without_fragment_gaps() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "## devo-cli -- Binary entry point that assembles all crates\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "4 source files, produces the devo binary.\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Command dispatch (/crates/cli/src/main.rs)\n\n".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "devo -> run_agent() interactive TUI (default)\n".to_string(), + )); + + let rows = rendered_rows(&widget, 180, 24); + let indices = indices_containing( + &rows, + &[ + "devo-cli", + "4 source files", + "Command dispatch", + "run_agent", + ], + ); + + assert_eq!( + indices + .windows(2) + .map(|pair| pair[1] - pair[0]) + .collect::>(), + vec![2, 2, 2], + "expected active assistant markdown blocks to have one separator row, not doubled gaps:\n{}", + rows.join("\n") + ); +} + #[test] fn committed_assistant_markdown_does_not_double_wrap() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -891,6 +1190,102 @@ fn committed_assistant_markdown_does_not_double_wrap() { ); } +#[test] +fn committed_assistant_multiline_text_has_no_extra_blank_rows() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "Line1\nLine2\nLine3\n".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + }); + + let lines = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( + widget.drain_scrollback_lines(80), + )); + let line1 = lines + .iter() + .position(|line| line.contains("Line1")) + .unwrap(); + let line2 = lines + .iter() + .position(|line| line.contains("Line2")) + .unwrap(); + let line3 = lines + .iter() + .position(|line| line.contains("Line3")) + .unwrap(); + assert_eq!(line2, line1 + 1, "unexpected lines:\n{}", lines.join("\n")); + assert_eq!(line3, line2 + 1, "unexpected lines:\n{}", lines.join("\n")); +} + +#[test] +fn tool_call_start_and_finish_are_both_visible_in_history() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let _ = widget.drain_scrollback_lines(80); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::ToolCall { + tool_use_id: "tool-1".to_string(), + summary: "powershell -NoProfile -Command Get-Date".to_string(), + }); + + let running = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + running.contains("Running powershell -NoProfile -Command Get-Date"), + "expected running tool cell, got:\n{running}" + ); + + widget.handle_worker_event(crate::events::WorkerEvent::ToolResult { + tool_use_id: "tool-1".to_string(), + title: "powershell -NoProfile -Command Get-Date".to_string(), + preview: "2026-05-09".to_string(), + is_error: false, + truncated: false, + }); + + let ran = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!( + ran.contains("Ran powershell -NoProfile -Command Get-Date"), + "expected ran tool cell, got:\n{ran}" + ); + assert!( + ran.contains("2026-05-09"), + "expected tool output, got:\n{ran}" + ); +} + #[test] fn reasoning_text_commits_to_history_when_turn_finishes() { let cwd = std::env::current_dir().expect("current directory is available"); @@ -1016,17 +1411,24 @@ fn reasoning_and_assistant_stream_in_separate_cells() { trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); assert!( !scrollback_contains_text(&committed_before_reasoning_complete, "final answer line 1"), - "assistant output should stay live until reasoning completes" + "assistant output should stay live, not drain to scrollback while reasoning is pending" + ); + let active_before_reasoning_complete = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + active_before_reasoning_complete.contains("final answer line 1"), + "assistant output should remain visible in the active viewport:\n{active_before_reasoning_complete}" ); widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( "thinking".to_string(), )); + // Reasoning is now committed to scrollback on ReasoningCompleted, + // no longer visible in the live viewport. let after = rendered_rows(&widget, 80, 16).join("\n"); assert!( - after.contains("thinking"), - "reasoning text should remain visible after completion:\n{after}" + !after.contains("thinking"), + "reasoning text should commit to scrollback, not remain in viewport:\n{after}" ); let committed_after_reasoning_complete = @@ -1037,8 +1439,210 @@ fn reasoning_and_assistant_stream_in_separate_cells() { .map(|span| span.content.as_ref()) .collect::(); assert!( - committed_after_text.contains("final answer line 1"), - "assistant output should flush once reasoning completes: {committed_after_reasoning_complete:?}" + committed_after_text.contains("thinking"), + "reasoning text should be in scrollback after ReasoningCompleted: {committed_after_reasoning_complete:?}" + ); + let after_reasoning_rows = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + after_reasoning_rows.contains("final answer line 2"), + "undrained assistant output should remain active after reasoning completes:\n{after_reasoning_rows}" + ); +} + +#[test] +fn lifecycle_text_items_render_as_ordered_sibling_cells() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "Line1\nLine2\n".to_string(), + }); + + let rows = rendered_rows(&widget, 80, 16); + let reasoning_row = find_row_index(&rows, "thinking").expect("missing reasoning row"); + let line1 = find_row_index(&rows, "Line1").expect("missing assistant row"); + let line2 = find_row_index(&rows, "Line2").expect("missing second assistant row"); + assert_eq!( + line1, + reasoning_row + 2, + "unexpected rows:\n{}", + rows.join("\n") + ); + assert_eq!(line2, line1 + 1, "unexpected rows:\n{}", rows.join("\n")); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + final_text: "thinking".to_string(), + }); + let rows_after_reasoning = rendered_rows(&widget, 80, 16); + assert!( + !rows_after_reasoning + .iter() + .any(|row| row.contains("thinking")), + "completed reasoning should leave active viewport:\n{}", + rows_after_reasoning.join("\n") + ); + assert!( + rows_after_reasoning.iter().any(|row| row.contains("Line1")), + "assistant should remain active:\n{}", + rows_after_reasoning.join("\n") + ); +} + +#[test] +fn lifecycle_text_items_keep_reasoning_before_assistant_when_events_arrive_out_of_order() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "answer line\n".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking text".to_string(), + }); + + let rows = rendered_rows(&widget, 80, 16); + let reasoning_row = find_row_index(&rows, "thinking text").expect("missing reasoning row"); + let assistant_row = find_row_index(&rows, "answer line").expect("missing assistant row"); + assert!( + reasoning_row < assistant_row, + "reasoning should render above assistant:\n{}", + rows.join("\n") + ); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + final_text: "answer line".to_string(), + }); + let committed_before_reasoning = widget.drain_scrollback_lines(80); + assert!( + !scrollback_contains_text(&committed_before_reasoning, "answer line"), + "assistant should wait for prior reasoning before committing: {committed_before_reasoning:?}" + ); + + widget.handle_worker_event(crate::events::WorkerEvent::TextItemCompleted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + final_text: "thinking text".to_string(), + }); + let committed = scrollback_plain_lines(&trim_trailing_blank_scrollback_lines( + widget.drain_scrollback_lines(80), + )) + .join("\n"); + let reasoning_index = committed + .find("thinking text") + .expect("missing committed reasoning"); + let assistant_index = committed + .find("answer line") + .expect("missing committed assistant"); + assert!( + reasoning_index < assistant_index, + "reasoning should commit before assistant:\n{committed}" + ); +} + +#[test] +fn assistant_stream_commit_tick_runs_while_reasoning_is_pending() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + let reasoning_id = ItemId::new(); + let assistant_id = ItemId::new(); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: reasoning_id, + kind: crate::events::TextItemKind::Reasoning, + delta: "thinking text".to_string(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemStarted { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextItemDelta { + item_id: assistant_id, + kind: crate::events::TextItemKind::Assistant, + delta: "first line\nsecond line\n".to_string(), + }); + + widget.pre_draw_tick(); + let committed = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + let active = rendered_rows(&widget, 80, 16).join("\n"); + assert!( + !committed.contains("first line"), + "assistant stream should stay out of scrollback until completion:\n{committed}" + ); + assert!( + active.contains("first line"), + "assistant stream should remain visible even with pending reasoning:\n{active}" ); } @@ -1084,6 +1688,7 @@ fn slash_model_opens_model_picker_instead_of_printing_current_model() { app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(model.clone())), initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -1235,41 +1840,22 @@ fn streaming_controller_is_initialized_and_commit_ticks_drain_lines() { reasoning_effort: None, turn_id: Default::default(), }); - assert!(widget.has_stream_controller()); + assert!(!widget.has_stream_controller()); widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( "first line\nsecond line\n".to_string(), )); + assert!(widget.has_stream_controller()); widget.pre_draw_tick(); - let first_pass = widget - .drain_scrollback_lines(80) - .into_iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); + let first_pass = rendered_rows(&widget, 80, 12).join("\n"); assert!(first_pass.contains("first line")); - assert!(!first_pass.contains("second line")); + assert!(first_pass.contains("second line")); + let first_scrollback = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); + assert!(!first_scrollback.contains("first line")); widget.pre_draw_tick(); - let second_pass = widget - .drain_scrollback_lines(80) - .into_iter() - .map(|line| { - line.line - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect::>() - .join("\n"); + let second_pass = rendered_rows(&widget, 80, 12).join("\n"); assert!(second_pass.contains("second line")); } @@ -1342,6 +1928,7 @@ fn model_selection_updates_session_projection_and_emits_context_override() { app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(model.clone())), initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -1381,7 +1968,7 @@ fn model_selection_updates_session_projection_and_emits_context_override() { model: Some("second-model".to_string()), thinking: Some("high".to_string()), sandbox: None, - approval_policy: None, + approval_policy: Some("on-request".to_string()), }) ); } @@ -1410,6 +1997,7 @@ fn model_selection_with_thinking_support_waits_for_second_step() { app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(model)), initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -1463,6 +2051,7 @@ fn model_selection_without_thinking_support_finishes_immediately() { app_event_tx: AppEventSender::new(app_event_tx), initial_session: TuiSessionState::new(cwd, Some(base_model)), initial_thinking_selection: None, + initial_permission_preset: devo_protocol::PermissionPreset::Default, initial_user_message: None, enhanced_keys_supported: true, is_first_run: false, @@ -1516,15 +2105,32 @@ fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( "line one\nline two\nline three\n".to_string(), )); - // Complete reasoning — this triggers the flush + // Complete reasoning; assistant stays active until its own item or turn completes. widget.handle_worker_event(crate::events::WorkerEvent::ReasoningCompleted( "thinking".to_string(), )); + let committed = trim_trailing_blank_scrollback_lines(widget.drain_scrollback_lines(80)); + let committed_text = committed + .iter() + .flat_map(|l| l.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(committed_text.contains("thinking")); + assert!(!committed_text.contains("line one")); + + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + }); + let committed = widget.drain_scrollback_lines(80); - // The three assistant lines should be in a single cell, so there should be - // no blank separator lines between them. Expect exactly 3 non-blank content - // lines (plus possibly a blank separator between header and this cell). let non_blank: Vec<&crate::history_cell::ScrollbackLine> = committed .iter() .filter(|l| { @@ -1544,6 +2150,55 @@ fn flushed_assistant_lines_after_reasoning_are_in_one_cell() { assert!(text.contains("line three")); } +#[test] +fn completed_streaming_assistant_consolidates_to_source_backed_cell() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd); + + let _ = widget.drain_scrollback_lines(80); + widget.handle_worker_event(crate::events::WorkerEvent::TurnStarted { + model: "test-model".to_string(), + thinking: None, + reasoning_effort: None, + turn_id: Default::default(), + }); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "## Architecture\n\nA. Input pipeline\n\n".to_string(), + )); + widget.pre_draw_tick(); + widget.handle_worker_event(crate::events::WorkerEvent::TextDelta( + "TuiEvent".to_string(), + )); + widget.handle_worker_event(crate::events::WorkerEvent::TurnFinished { + stop_reason: "Completed".to_string(), + turn_count: 1, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + }); + + let committed = widget.drain_scrollback_lines(80); + let text = committed + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!( + text.matches("Architecture").count(), + 1, + "completed assistant history should be consolidated without replay: {text}" + ); + assert!(text.contains("TuiEvent")); +} + #[test] fn reasoning_appears_exactly_once_after_full_turn() { let cwd = std::env::current_dir().expect("current directory is available"); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 710014b..f82b373 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -1,6 +1,7 @@ use std::time::Instant; use crate::app_command::InputHistoryDirection; +use devo_core::ItemId; use devo_core::SessionId; use devo_protocol::ProviderWireApi; use devo_protocol::ReasoningEffort; @@ -55,6 +56,20 @@ pub(crate) enum WorkerEvent { }, /// A steer (/btw) was accepted by the server. SteerAccepted { turn_id: TurnId }, + /// A streamed assistant or reasoning text item started. + TextItemStarted { item_id: ItemId, kind: TextItemKind }, + /// Incremental text for a streamed assistant or reasoning item. + TextItemDelta { + item_id: ItemId, + kind: TextItemKind, + delta: String, + }, + /// A streamed assistant or reasoning text item completed. + TextItemCompleted { + item_id: ItemId, + kind: TextItemKind, + final_text: String, + }, /// Incremental assistant text. TextDelta(String), /// Incremental reasoning text. @@ -90,6 +105,23 @@ pub(crate) enum WorkerEvent { /// Whether the preview was truncated for display. truncated: bool, }, + ApprovalRequest { + session_id: SessionId, + turn_id: TurnId, + approval_id: String, + action_summary: String, + justification: String, + resource: Option, + available_scopes: Vec, + path: Option, + host: Option, + target: Option, + }, + ApprovalDecision { + approval_id: String, + decision: String, + scope: String, + }, /// Live usage update for the active turn. UsageUpdated { /// Total input tokens accumulated in the session. @@ -248,6 +280,12 @@ pub(crate) enum WorkerEvent { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TextItemKind { + Assistant, + Reasoning, +} + /// One rendered transcript item shown in the history pane. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TranscriptItem { @@ -328,6 +366,7 @@ pub(crate) enum TranscriptItemKind { ToolResult, /// Failed tool result or runtime error. Error, + Approval, /// Local UI/system note that is not model-authored content. System, /// Turn summary with model name and duration. diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index 640914b..d0e5cd1 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -54,29 +54,20 @@ use std::time::Instant; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -pub(crate) const AI_REPLY_WRAP_WIDTH: usize = 80; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ScrollbackWrapPolicy { - LimitToEightyColumns, - NoAdditionalWrapLimit, -} - #[derive(Debug, Clone)] pub(crate) struct ScrollbackLine { pub(crate) line: Line<'static>, - pub(crate) wrap_policy: ScrollbackWrapPolicy, } impl ScrollbackLine { - pub(crate) fn new(line: Line<'static>, wrap_policy: ScrollbackWrapPolicy) -> Self { - Self { line, wrap_policy } + pub(crate) fn new(line: Line<'static>) -> Self { + Self { line } } } impl From> for ScrollbackLine { fn from(line: Line<'static>) -> Self { - Self::new(line, ScrollbackWrapPolicy::LimitToEightyColumns) + Self::new(line) } } @@ -162,10 +153,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn transcript_animation_tick(&self) -> Option { None } - - fn scrollback_wrap_policy(&self) -> ScrollbackWrapPolicy { - ScrollbackWrapPolicy::NoAdditionalWrapLimit - } } impl Renderable for Box { @@ -284,6 +271,20 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec lines } +pub(crate) fn collapse_consecutive_blank_lines(lines: Vec>) -> Vec> { + let mut collapsed = Vec::with_capacity(lines.len()); + let mut last_was_blank = false; + for line in lines { + let is_blank = line.spans.iter().all(|span| span.content.trim().is_empty()); + if is_blank && last_was_blank { + continue; + } + last_was_blank = is_blank; + collapsed.push(line); + } + collapsed +} + impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let wrap_width = width @@ -302,7 +303,7 @@ impl HistoryCell for UserHistoryCell { let prefix_style = Style::default().fg(accent); let blank_prefixed_line = || { Line::from(vec![ - Span::styled("┃ ", prefix_style), + Span::styled("▌ ", prefix_style), Span::styled(String::new(), style), ]) .style(style) @@ -340,7 +341,7 @@ impl HistoryCell for UserHistoryCell { let mut lines: Vec> = vec![blank_prefixed_line()]; if let Some(wrapped_message) = wrapped_message { - lines.extend(prefix_lines(wrapped_message, "┃ ".cyan(), "┃ ".cyan())); + lines.extend(prefix_lines(wrapped_message, "▌ ".cyan(), "▌ ".cyan())); } lines.push(blank_prefixed_line()); @@ -370,7 +371,6 @@ impl ReasoningSummaryCell { } fn lines(&self, width: u16) -> Vec> { - let width = width.min(AI_REPLY_WRAP_WIDTH as u16); let mut lines: Vec> = Vec::new(); append_markdown( &self.content, @@ -394,7 +394,7 @@ impl ReasoningSummaryCell { adaptive_wrap_lines( &summary_lines, RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) + .initial_indent("▌ ".dim().into()) .subsequent_indent(" ".into()), ) } @@ -412,10 +412,6 @@ impl HistoryCell for ReasoningSummaryCell { fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } - - fn scrollback_wrap_policy(&self) -> ScrollbackWrapPolicy { - ScrollbackWrapPolicy::LimitToEightyColumns - } } #[derive(Debug)] @@ -424,7 +420,6 @@ pub(crate) struct AgentMessageCell { initial_prefix: Line<'static>, subsequent_prefix: Line<'static>, is_stream_continuation: bool, - max_wrap_width: Option, } impl AgentMessageCell { @@ -432,13 +427,12 @@ impl AgentMessageCell { Self { lines, initial_prefix: if is_first_line { - "• ".dim().into() + "▌ ".dim().into() } else { " ".into() }, subsequent_prefix: " ".into(), is_stream_continuation: !is_first_line, - max_wrap_width: None, } } @@ -453,7 +447,6 @@ impl AgentMessageCell { initial_prefix: initial_prefix.into(), subsequent_prefix: subsequent_prefix.into(), is_stream_continuation, - max_wrap_width: Some(AI_REPLY_WRAP_WIDTH), } } @@ -468,37 +461,68 @@ impl AgentMessageCell { initial_prefix: initial_prefix.into(), subsequent_prefix: subsequent_prefix.into(), is_stream_continuation, - max_wrap_width: None, } } } impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { - let width = self - .max_wrap_width - .map_or(width, |max_wrap_width| width.min(max_wrap_width as u16)); - adaptive_wrap_lines( + collapse_consecutive_blank_lines(adaptive_wrap_lines( &self.lines, RtOptions::new(width as usize) .initial_indent(self.initial_prefix.clone()) .subsequent_indent(self.subsequent_prefix.clone()), - ) + )) } fn is_stream_continuation(&self) -> bool { self.is_stream_continuation } +} - fn scrollback_wrap_policy(&self) -> ScrollbackWrapPolicy { - if self.max_wrap_width == Some(AI_REPLY_WRAP_WIDTH) { - ScrollbackWrapPolicy::LimitToEightyColumns - } else { - ScrollbackWrapPolicy::NoAdditionalWrapLimit +#[derive(Debug)] +pub(crate) struct AgentMarkdownCell { + markdown_source: String, + cwd: PathBuf, + initial_prefix: Line<'static>, + subsequent_prefix: Line<'static>, +} + +impl AgentMarkdownCell { + pub(crate) fn new( + markdown_source: String, + cwd: &Path, + initial_prefix: impl Into>, + subsequent_prefix: impl Into>, + ) -> Self { + Self { + markdown_source, + cwd: cwd.to_path_buf(), + initial_prefix: initial_prefix.into(), + subsequent_prefix: subsequent_prefix.into(), } } } +impl HistoryCell for AgentMarkdownCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines = Vec::new(); + append_markdown( + &self.markdown_source, + /*width*/ None, + Some(self.cwd.as_path()), + &mut lines, + ); + let lines = collapse_consecutive_blank_lines(lines); + collapse_consecutive_blank_lines(adaptive_wrap_lines( + &lines, + RtOptions::new(width as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()), + )) + } +} + #[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, @@ -573,9 +597,9 @@ impl HistoryCell for UnifiedExecInteractionCell { let waited_only = self.stdin.is_empty(); let mut header_spans = if waited_only { - vec!["• Waited for background terminal".bold()] + vec!["▌ ".cyan(), "Waited for background terminal".bold()] } else { - vec!["↳ ".dim(), "Interacted with background terminal".bold()] + vec!["▌ ".cyan(), "Interacted with background terminal".bold()] }; if let Some(command) = &self.command_display && !command.is_empty() @@ -593,11 +617,8 @@ impl HistoryCell for UnifiedExecInteractionCell { return out; } - let input_lines: Vec> = self - .stdin - .lines() - .map(|line| Line::from(line.to_string())) - .collect(); + let input_lines: Vec> = + self.stdin.lines().map(render_terminal_input).collect(); let input_wrapped = adaptive_wrap_lines( input_lines, @@ -617,6 +638,13 @@ pub(crate) fn new_unified_exec_interaction( UnifiedExecInteractionCell::new(command_display, stdin) } +fn render_terminal_input(input: &str) -> Line<'static> { + if input.is_empty() { + return Line::from("⏎".dim()); + } + Line::from(input.to_string()) +} + #[derive(Debug)] struct UnifiedExecProcessesCell { processes: Vec, @@ -647,11 +675,11 @@ impl HistoryCell for UnifiedExecProcessesCell { out.push("".into()); if self.processes.is_empty() { - out.push(" • No background terminals running.".italic().into()); + out.push(" ▌ No background terminals running.".italic().into()); return out; } - let prefix = " • "; + let prefix = " ▌ "; let prefix_width = UnicodeWidthStr::width(prefix); let truncation_suffix = " [...]"; let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); @@ -823,6 +851,32 @@ pub fn new_guardian_approved_action_request(summary: String) -> Box Box { + let mut lines = vec![Line::from(vec![ + "Permission required: ".yellow().bold(), + Span::from(title), + ])]; + for line in body.lines().filter(|line| !line.trim().is_empty()) { + lines.push(Line::from(line.to_string()).dim()); + } + lines.push(Line::from(vec![ + "Press ".dim(), + "y".bold(), + " once, ".dim(), + "s".bold(), + " session, ".dim(), + "n".bold(), + " deny, ".dim(), + "Esc".bold(), + " cancel".dim(), + ])); + Box::new(PlainHistoryCell::new(prefix_lines( + lines, + "? ".yellow(), + " ".into(), + ))) +} + /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { @@ -1238,7 +1292,7 @@ impl HistoryCell for DeprecationNoticeCell { } pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { - let mut line = vec!["• ".dim(), message.into()]; + let mut line = vec!["▌ ".dim(), message.into()]; if let Some(hint) = hint { line.push(" ".into()); line.push(hint.dark_gray()); @@ -1366,7 +1420,7 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor let display_path = display_path_for(&path, cwd); let lines: Vec> = vec![ - vec!["• ".dim(), "Viewed Image".bold()].into(), + vec!["▌ ".dim(), "Viewed Image".bold()].into(), vec![" └ ".dim(), display_path.dim()].into(), ]; @@ -1381,7 +1435,7 @@ pub(crate) fn new_image_generation_call( let detail = revised_prompt.unwrap_or_else(|| call_id.clone()); let mut lines: Vec> = vec![ - vec!["• ".dim(), "Generated Image:".bold()].into(), + vec!["▌ ".dim(), "Generated Image:".bold()].into(), vec![" └ ".dim(), detail.dim()].into(), ]; if let Some(saved_path) = saved_path { @@ -1459,7 +1513,7 @@ impl HistoryCell for FinalMessageSeparator { return vec![Line::from_iter(["─".repeat(width as usize).dim()])]; } - let label = format!("─ {} ─", label_parts.join(" • ")); + let label = format!("─ {} ─", label_parts.join(" ▌ ")); let (label, _suffix, label_width) = take_prefix_by_width(&label, width as usize); vec![ Line::from_iter([ diff --git a/crates/tui/src/host.rs b/crates/tui/src/host.rs index 57255a3..7aada57 100644 --- a/crates/tui/src/host.rs +++ b/crates/tui/src/host.rs @@ -24,6 +24,7 @@ use crate::chatwidget::TuiSessionState; use crate::events::WorkerEvent; use crate::onboarding::save_last_used_model; use crate::onboarding::save_onboarding_config; +use crate::onboarding::save_project_permission_preset; use crate::onboarding::save_thinking_selection; use crate::render::renderable::Renderable; use crate::tui::Tui; @@ -64,6 +65,19 @@ enum LoopAction { ClearAndExit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CtrlCKeyAction { + PromptInterruptWithEsc, + PromptExitConfirmation, + Exit, +} + +struct AppCommandContext<'a, M: ModelCatalog> { + model_catalog: &'a M, + default_provider: ProviderWireApi, + project_config_key: &'a str, +} + /// RAII guard that restores terminal modes exactly once after the TUI loop ends. /// /// The restore is owned by the outer host instead of `Tui::drop()`: @@ -136,6 +150,7 @@ pub async fn run_interactive_tui(config: InteractiveTuiConfig) -> Result Result Result Result {} LoopAction::ClearAndExit => { @@ -278,7 +298,7 @@ fn clear_before_exit(tui: &mut Tui) -> Result<()> { fn handle_tui_event( tui_event: Option, tui: &mut Tui, - worker: &QueryWorkerHandle, + _worker: &QueryWorkerHandle, chat_widget: &mut ChatWidget, loop_state: &mut InteractiveLoopState, ) -> Result { @@ -325,21 +345,19 @@ fn handle_tui_event( })?; } TuiEvent::Key(key) => { - // Let Ctrl-C interrupt active work first, then require a second press to exit. + // Keep Ctrl-C available for terminal copy workflows while work is + // active. Cancellation is owned by the bottom pane's Esc flow. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - if loop_state.busy { - worker.interrupt_turn()?; - chat_widget.set_status_message("Interrupted;"); - } else { - let now = Instant::now(); - if loop_state - .last_ctrl_c_at - .is_some_and(|last| now.duration_since(last) <= Duration::from_secs(2)) - { + match handle_ctrl_c_key(loop_state, Instant::now()) { + CtrlCKeyAction::PromptInterruptWithEsc => { + chat_widget.set_status_message("Press Esc twice to interrupt"); + } + CtrlCKeyAction::PromptExitConfirmation => { + chat_widget.set_status_message("Press Ctrl-C again to exit"); + } + CtrlCKeyAction::Exit => { return Ok(LoopAction::ClearAndExit); } - loop_state.last_ctrl_c_at = Some(now); - chat_widget.set_status_message("Press Ctrl-C again to exit"); } return Ok(LoopAction::Continue); } @@ -372,14 +390,30 @@ fn handle_tui_event( Ok(LoopAction::Continue) } +fn handle_ctrl_c_key(loop_state: &mut InteractiveLoopState, now: Instant) -> CtrlCKeyAction { + if loop_state.busy { + loop_state.last_ctrl_c_at = None; + return CtrlCKeyAction::PromptInterruptWithEsc; + } + + if loop_state + .last_ctrl_c_at + .is_some_and(|last| now.duration_since(last) <= Duration::from_secs(2)) + { + return CtrlCKeyAction::Exit; + } + + loop_state.last_ctrl_c_at = Some(now); + CtrlCKeyAction::PromptExitConfirmation +} + fn handle_app_event( app_event: Option, worker: &QueryWorkerHandle, chat_widget: &mut ChatWidget, tui: &mut Tui, loop_state: &mut InteractiveLoopState, - model_catalog: &impl ModelCatalog, - default_provider: ProviderWireApi, + context: &AppCommandContext<'_, impl ModelCatalog>, ) -> Result { let Some(app_event) = app_event else { return Ok(LoopAction::ClearAndExit); @@ -400,15 +434,7 @@ fn handle_app_event( if let AppEvent::Command(command) = &app_event { chat_widget.handle_app_event(app_event.clone()); // Commands that affect sessions, providers, or turns are forwarded to the worker. - handle_app_command( - command, - worker, - chat_widget, - tui, - loop_state, - model_catalog, - default_provider, - )?; + handle_app_command(command, worker, chat_widget, tui, loop_state, context)?; return Ok(LoopAction::Continue); } chat_widget.handle_app_event(app_event); @@ -498,6 +524,9 @@ fn handle_worker_event( loop_state.session_switch_pending = false; } WorkerEvent::TextDelta(_) + | WorkerEvent::TextItemStarted { .. } + | WorkerEvent::TextItemDelta { .. } + | WorkerEvent::TextItemCompleted { .. } | WorkerEvent::ReasoningDelta(_) | WorkerEvent::AssistantMessageCompleted(_) | WorkerEvent::ReasoningCompleted(_) @@ -510,6 +539,8 @@ fn handle_worker_event( | WorkerEvent::SessionTitleUpdated { .. } | WorkerEvent::InputHistoryLoaded { .. } | WorkerEvent::InputQueueUpdated { .. } + | WorkerEvent::ApprovalRequest { .. } + | WorkerEvent::ApprovalDecision { .. } | WorkerEvent::SteerAccepted { .. } => {} } if matches!(&worker_event, WorkerEvent::SessionsListed { .. }) { @@ -530,14 +561,14 @@ fn handle_app_command( chat_widget: &mut ChatWidget, tui: &mut Tui, loop_state: &mut InteractiveLoopState, - model_catalog: &impl ModelCatalog, - default_provider: ProviderWireApi, + context: &AppCommandContext<'_, impl ModelCatalog>, ) -> Result<()> { match command { AppCommand::UserTurn { input, model, thinking, + approval_policy, .. } => { if let Some(model) = model { @@ -554,7 +585,7 @@ fn handle_app_command( }) .collect::>() .join("\n"); - worker.submit_prompt(prompt)?; + worker.submit_prompt(prompt, approval_policy.clone())?; } AppCommand::SteerTurn { input, @@ -570,15 +601,36 @@ fn handle_app_command( .join("\n"); worker.submit_steer(prompt, *expected_turn_id)?; } + AppCommand::ApprovalRespond { + session_id, + turn_id, + approval_id, + decision, + scope, + } => { + worker.approval_respond( + *session_id, + *turn_id, + approval_id.clone(), + decision.clone(), + scope.clone(), + )?; + } + AppCommand::UpdatePermissions { preset } => { + worker.update_permissions(*preset)?; + save_project_permission_preset(context.project_config_key, *preset)?; + chat_widget.note_permissions_updated(*preset); + } AppCommand::OverrideTurnContext { model, thinking, .. } => { if let Some(model) = model { worker.set_model(model.clone())?; - let provider = model_catalog + let provider = context + .model_catalog .get(model) .map(Model::provider_wire_api) - .unwrap_or(default_provider); + .unwrap_or(context.default_provider); save_last_used_model(/*wire_api*/ None, provider, model)?; } if let Some(thinking) = thinking { @@ -613,10 +665,11 @@ fn handle_app_command( .get("api_key") .and_then(serde_json::Value::as_str) .map(ToOwned::to_owned); - let provider = model_catalog + let provider = context + .model_catalog .get(&model) .map(Model::provider_wire_api) - .unwrap_or(default_provider); + .unwrap_or(context.default_provider); loop_state.pending_onboarding = Some(PendingOnboarding { provider, model: model.clone(), @@ -656,3 +709,36 @@ fn handle_app_command( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn ctrl_c_while_busy_prompts_for_esc_without_arming_exit() { + let mut loop_state = InteractiveLoopState { + busy: true, + last_ctrl_c_at: Some(Instant::now()), + ..InteractiveLoopState::default() + }; + + let action = handle_ctrl_c_key(&mut loop_state, Instant::now()); + + assert_eq!(CtrlCKeyAction::PromptInterruptWithEsc, action); + assert_eq!(None, loop_state.last_ctrl_c_at); + } + + #[test] + fn ctrl_c_when_idle_requires_second_press_to_exit() { + let now = Instant::now(); + let mut loop_state = InteractiveLoopState::default(); + + let first = handle_ctrl_c_key(&mut loop_state, now); + let second = handle_ctrl_c_key(&mut loop_state, now + Duration::from_secs(1)); + + assert_eq!(CtrlCKeyAction::PromptExitConfirmation, first); + assert_eq!(CtrlCKeyAction::Exit, second); + } +} diff --git a/crates/tui/src/insert_history.rs b/crates/tui/src/insert_history.rs index 515781b..01a34f3 100644 --- a/crates/tui/src/insert_history.rs +++ b/crates/tui/src/insert_history.rs @@ -3,11 +3,6 @@ use std::io; use std::io::Write; use crate::history_cell::ScrollbackLine; -use crate::history_cell::ScrollbackWrapPolicy; -use crate::wrapping::RtOptions; -use crate::wrapping::adaptive_wrap_line; -use crate::wrapping::line_contains_url_like; -use crate::wrapping::line_has_mixed_url_and_non_url_tokens; use crossterm::Command; use crossterm::cursor::MoveDown; use crossterm::cursor::MoveTo; @@ -88,39 +83,17 @@ where let last_cursor_pos = terminal.last_known_cursor_pos; let writer = terminal.backend_mut(); - // Pre-wrap lines for terminal scrollback. Three paths: - // - // - URL-only-ish lines are kept intact (no hard newlines inserted) so that - // terminal emulators can match them as clickable links. The - // terminal will character-wrap these lines at the viewport - // boundary. - // - Mixed lines (URL + non-URL prose) are adaptively wrapped so - // non-URL text still wraps naturally while URL tokens remain - // unsplit. - // - Non-URL lines also flow through adaptive wrapping; behavior is - // equivalent to standard wrapping when no URL is present. let screen_wrap_width = usize::from(screen_size.width.max(1)); - let wrap_width = screen_wrap_width.min(80); let mut wrapped = Vec::new(); let mut wrapped_rows = 0usize; for scrollback_line in &lines { - let line_wrapped = match scrollback_line.wrap_policy { - ScrollbackWrapPolicy::NoAdditionalWrapLimit => vec![scrollback_line.line.clone()], - ScrollbackWrapPolicy::LimitToEightyColumns => { - let line = &scrollback_line.line; - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) { - vec![line.clone()] - } else { - adaptive_wrap_line(line, RtOptions::new(wrap_width)) - } - } - }; - wrapped_rows += line_wrapped - .iter() - .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(screen_wrap_width)) - .sum::(); - wrapped.extend(line_wrapped); + wrapped_rows += scrollback_line + .line + .width() + .max(1) + .div_ceil(screen_wrap_width); + wrapped.push(scrollback_line.line.clone()); } let wrapped_lines = wrapped_rows as u16; @@ -703,69 +676,6 @@ mod tests { ); } - #[test] - fn vt100_prefixed_mixed_url_line_wraps_suffix_words_together() { - let width: u16 = 24; - let height: u16 = 10; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let viewport = Rect::new(0, height - 1, width, 1); - term.set_viewport_area(viewport); - - let url = "https://example.test/path/abcdef12345"; - let line: Line<'static> = Line::from(vec![ - " │ ".into(), - "see ".into(), - url.into(), - " tail words".into(), - ]); - - insert_history_lines(&mut term, vec![line.into()]).expect("insert mixed history"); - - let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); - assert!( - rows.iter().any(|r| r.contains("│ see")), - "expected prefixed prose before URL, rows: {rows:?}" - ); - assert!( - rows.iter().any(|r| r.contains("tail words")), - "expected suffix words to wrap as a phrase, rows: {rows:?}" - ); - } - - #[test] - fn vt100_non_ai_history_line_does_not_apply_eighty_column_wrap_limit() { - let width: u16 = 24; - let height: u16 = 10; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let viewport = Rect::new(0, height - 1, width, 1); - term.set_viewport_area(viewport); - - let url = "https://example.test/path/abcdef12345"; - let line: Line<'static> = Line::from(vec![ - " │ ".into(), - "see ".into(), - url.into(), - " tail words".into(), - ]); - - insert_history_lines( - &mut term, - vec![ScrollbackLine::new( - line, - ScrollbackWrapPolicy::NoAdditionalWrapLimit, - )], - ) - .expect("insert non-ai history"); - - let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); - assert!( - rows.iter().any(|r| r.contains("ta")) && rows.iter().any(|r| r.contains("il words")), - "expected terminal-width wrapping instead of forced 80-column wrapping, rows: {rows:?}" - ); - } - #[test] fn vt100_unwrapped_url_like_clears_continuation_rows() { let width: u16 = 20; diff --git a/crates/tui/src/markdown_stream.rs b/crates/tui/src/markdown_stream.rs index 7bbce72..3ac176b 100644 --- a/crates/tui/src/markdown_stream.rs +++ b/crates/tui/src/markdown_stream.rs @@ -1,55 +1,141 @@ +//! Collects markdown stream source at newline boundaries. +//! +//! `MarkdownStreamCollector` buffers incoming token deltas and exposes a commit boundary at each +//! newline. The stream controllers (`streaming/controller.rs`) call `commit_complete_source()` +//! after each newline-bearing delta to obtain the completed prefix for re-rendering, leaving the +//! trailing incomplete line in the buffer for the next delta. +//! +//! On finalization, `finalize_and_drain_source()` flushes whatever remains (the last line, which +//! may lack a trailing newline). + +#[cfg(test)] use ratatui::text::Line; use std::path::Path; +#[cfg(test)] use std::path::PathBuf; +#[cfg(test)] use crate::markdown; -/// Newline-gated accumulator that renders markdown and commits only fully -/// completed logical lines. +/// Newline-gated accumulator that buffers raw markdown source and commits only completed lines. +/// +/// The buffer tracks how many source bytes have already been committed via +/// `committed_source_len`, so each `commit_complete_source()` call returns only the newly +/// completed portion. This design lets the stream controller re-render the entire accumulated +/// source while only appending new content. +/// +/// The collector does not parse markdown in production. It only defines stable source boundaries; +/// rendering lives in the stream controllers so width changes can re-render from one accumulated +/// source string. pub(crate) struct MarkdownStreamCollector { buffer: String, + committed_source_len: usize, + #[cfg(test)] committed_line_count: usize, width: Option, + #[cfg(test)] cwd: PathBuf, } impl MarkdownStreamCollector { - /// Create a collector that renders markdown using `cwd` for local file-link display. + /// Create a collector that accumulates raw markdown deltas. /// - /// The collector snapshots `cwd` into owned state because stream commits can happen long after - /// construction. The same `cwd` should be reused for the entire stream lifecycle; mixing - /// different working directories within one stream would make the same link render with - /// different path prefixes across incremental commits. + /// `width` and `cwd` are only used by test-only rendering helpers; production stream commits + /// operate on raw source boundaries. The collector snapshots `cwd` so test rendering keeps + /// local file-link display stable across incremental commits. pub fn new(width: Option, cwd: &Path) -> Self { + #[cfg(not(test))] + let _ = cwd; + Self { buffer: String::new(), + committed_source_len: 0, + #[cfg(test)] committed_line_count: 0, width, + #[cfg(test)] cwd: cwd.to_path_buf(), } } + /// Update the rendering width used by test-only line-commit helpers. + pub fn set_width(&mut self, width: Option) { + self.width = width; + } + + /// Reset all buffered source and commit bookkeeping. pub fn clear(&mut self) { self.buffer.clear(); - self.committed_line_count = 0; + self.committed_source_len = 0; + #[cfg(test)] + { + self.committed_line_count = 0; + } } + /// Append a raw streaming delta to the internal source buffer. pub fn push_delta(&mut self, delta: &str) { tracing::trace!("push_delta: {delta:?}"); self.buffer.push_str(delta); } + /// Returns raw source that has not yet been returned by `commit_complete_source`. + pub fn uncommitted_source(&self) -> &str { + &self.buffer[self.committed_source_len..] + } + + /// Commit newly completed raw markdown source up to the last newline. + /// + /// This returns only source that has not been returned by a previous commit. Calling it after a + /// delta without a newline returns `None`, which prevents the live stream from rendering + /// incomplete markdown blocks that may change meaning when the rest of the line arrives. + pub fn commit_complete_source(&mut self) -> Option { + let commit_end = self.buffer.rfind('\n').map(|idx| idx + 1)?; + if commit_end <= self.committed_source_len { + return None; + } + + let out = self.buffer[self.committed_source_len..commit_end].to_string(); + self.committed_source_len = commit_end; + Some(out) + } + + /// Finalize the stream and return any remaining raw source. + /// + /// Ensures the returned source chunk is newline-terminated when non-empty so callers can + /// safely run markdown block parsing on the final chunk. This method clears the collector; + /// callers should not invoke it until the stream is truly complete or interrupted output is + /// being intentionally consolidated. + pub fn finalize_and_drain_source(&mut self) -> String { + if self.committed_source_len >= self.buffer.len() { + self.clear(); + return String::new(); + } + + let mut out = self.buffer[self.committed_source_len..].to_string(); + if !out.ends_with('\n') { + out.push('\n'); + } + self.clear(); + out + } + /// Render the full buffer and return only the newly completed logical lines /// since the last commit. When the buffer does not end with a newline, the /// final rendered line is considered incomplete and is not emitted. + /// + /// This helper intentionally uses `append_markdown` (not + /// `append_markdown_agent`) so tests can isolate collector newline boundary + /// behavior without stream-controller holdback semantics. + #[cfg(test)] pub fn commit_complete_lines(&mut self) -> Vec> { - let source = self.buffer.clone(); - let last_newline_idx = source.rfind('\n'); - let source = if let Some(last_newline_idx) = last_newline_idx { - source[..=last_newline_idx].to_string() - } else { + let Some(commit_end) = self.buffer.rfind('\n').map(|idx| idx + 1) else { return Vec::new(); }; + if commit_end <= self.committed_source_len { + return Vec::new(); + } + let source = self.buffer[..commit_end].to_string(); let mut rendered: Vec> = Vec::new(); markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered); let mut complete_line_count = rendered.len(); @@ -68,47 +154,29 @@ impl MarkdownStreamCollector { let out_slice = &rendered[self.committed_line_count..complete_line_count]; let out = out_slice.to_vec(); + self.committed_source_len = commit_end; self.committed_line_count = complete_line_count; out } - /// Render the current buffer and return the still-pending logical lines that have not yet been - /// committed. - pub fn pending_lines(&self) -> Vec> { - if self.buffer.is_empty() { - return Vec::new(); - } - - let mut rendered: Vec> = Vec::new(); - markdown::append_markdown( - &self.buffer, - self.width, - Some(self.cwd.as_path()), - &mut rendered, - ); - - if self.committed_line_count >= rendered.len() { - return Vec::new(); - } - - rendered[self.committed_line_count..].to_vec() - } - /// Finalize the stream: emit all remaining lines beyond the last commit. /// If the buffer does not end with a newline, a temporary one is appended - /// for rendering. Optionally unwraps ```markdown language fences in - /// non-test builds. + /// for rendering. + #[cfg(test)] pub fn finalize_and_drain(&mut self) -> Vec> { - let raw_buffer = self.buffer.clone(); - let mut source: String = raw_buffer.clone(); + let mut source = self.buffer.clone(); + if source.is_empty() { + self.clear(); + return Vec::new(); + } if !source.ends_with('\n') { source.push('\n'); - } + }; tracing::debug!( - raw_len = raw_buffer.len(), + raw_len = self.buffer.len(), source_len = source.len(), "markdown finalize (raw length: {}, rendered length: {})", - raw_buffer.len(), + self.buffer.len(), source.len() ); tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---"); @@ -438,6 +506,42 @@ mod tests { .collect() } + #[tokio::test] + async fn table_header_commits_without_holdback() { + let mut c = super::MarkdownStreamCollector::new(/*width*/ None, &super::test_cwd()); + c.push_delta("| A | B |\n"); + let out1 = c.commit_complete_lines(); + let out1_str = lines_to_plain_strings(&out1); + assert_eq!(out1_str, vec!["| A | B |".to_string()]); + + c.push_delta("| --- | --- |\n"); + let out = c.commit_complete_lines(); + let out_str = lines_to_plain_strings(&out); + assert!( + !out_str.is_empty(), + "expected output to continue committing after delimiter: {out_str:?}" + ); + + c.push_delta("| 1 | 2 |\n"); + let out2 = c.commit_complete_lines(); + assert!( + !out2.is_empty(), + "expected output to continue committing after body row" + ); + + c.push_delta("\n"); + let _ = c.commit_complete_lines(); + } + + #[tokio::test] + async fn pipe_text_without_table_prefix_is_not_delayed() { + let mut c = super::MarkdownStreamCollector::new(/*width*/ None, &super::test_cwd()); + c.push_delta("Escaped pipe in text: a | b | c\n"); + let out = c.commit_complete_lines(); + let out_str = lines_to_plain_strings(&out); + assert_eq!(out_str, vec!["Escaped pipe in text: a | b | c".to_string()]); + } + #[tokio::test] async fn lists_and_fences_commit_without_duplication() { // List case @@ -744,4 +848,9 @@ mod tests { ]) .await; } + + #[tokio::test] + async fn table_like_lines_inside_fenced_code_are_not_held() { + assert_streamed_equals_full(&["```\n", "| a | b |\n", "```\n"]).await; + } } diff --git a/crates/tui/src/onboarding.rs b/crates/tui/src/onboarding.rs index 1eefbb4..ef7799d 100644 --- a/crates/tui/src/onboarding.rs +++ b/crates/tui/src/onboarding.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use devo_core::provider_id_for_endpoint; use devo_core::provider_name_for_endpoint; +use devo_protocol::PermissionPreset; use devo_protocol::ProviderWireApi; use devo_utils::find_devo_home; use toml::Value; @@ -121,6 +122,34 @@ pub(crate) fn save_theme_selection(name: &str) -> Result<()> { Ok(()) } +pub(crate) fn save_project_permission_preset( + project_key: &str, + preset: PermissionPreset, +) -> Result<()> { + let path = find_devo_home() + .context("could not determine user config path")? + .join("config.toml"); + let mut root = if path.exists() { + let data = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + data.parse::() + .with_context(|| format!("failed to parse {}", path.display()))? + } else { + Value::Table(Default::default()) + }; + root = merge_project_permission_preset(root, project_key, preset)?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let rendered = toml::to_string_pretty(&root)?; + + std::fs::write(&path, rendered) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + pub(crate) fn load_theme_selection() -> Option { let path = find_devo_home().ok()?.join("config.toml"); let data = std::fs::read_to_string(&path).ok()?; @@ -130,6 +159,33 @@ pub(crate) fn load_theme_selection() -> Option { .map(ToOwned::to_owned) } +fn merge_project_permission_preset( + mut root: Value, + project_key: &str, + preset: PermissionPreset, +) -> Result { + let table = root + .as_table_mut() + .context("config root must be a TOML table")?; + let projects = table + .entry("projects".to_string()) + .or_insert_with(|| Value::Table(Default::default())); + let projects_table = projects + .as_table_mut() + .context("projects must be a TOML table")?; + let project = projects_table + .entry(project_key.to_string()) + .or_insert_with(|| Value::Table(Default::default())); + let project_table = project + .as_table_mut() + .context("project permission entry must be a TOML table")?; + project_table.insert( + "permission_preset".to_string(), + Value::String(permission_preset_to_config_value(preset).to_string()), + ); + Ok(root) +} + fn merge_theme_selection(mut root: Value, name: &str) -> Result { let table = root .as_table_mut() @@ -138,6 +194,15 @@ fn merge_theme_selection(mut root: Value, name: &str) -> Result { Ok(root) } +fn permission_preset_to_config_value(preset: PermissionPreset) -> &'static str { + match preset { + PermissionPreset::ReadOnly => "read-only", + PermissionPreset::Default => "default", + PermissionPreset::AutoReview => "auto-review", + PermissionPreset::FullAccess => "full-access", + } +} + #[allow(dead_code)] fn merge_thinking_selection(mut root: Value, selection: Option<&str>) -> Result { let table = root @@ -618,4 +683,62 @@ model = "gpt-5.4" None ); } + + #[test] + fn merge_project_permission_preset_preserves_unrelated_config() { + let root: Value = r#" +model = "gpt-5.4" + +[projects.old] +permission_preset = "default" +custom = "keep" +"# + .parse() + .expect("parse"); + + let merged = + merge_project_permission_preset(root, "C:\\repo", PermissionPreset::FullAccess) + .expect("merge"); + + assert_eq!( + merged + .as_table() + .and_then(|table| table.get("model")) + .and_then(Value::as_str), + Some("gpt-5.4") + ); + assert_eq!( + merged + .as_table() + .and_then(|table| table.get("projects")) + .and_then(Value::as_table) + .and_then(|projects| projects.get("old")) + .and_then(Value::as_table) + .and_then(|project| project.get("permission_preset")) + .and_then(Value::as_str), + Some("default") + ); + assert_eq!( + merged + .as_table() + .and_then(|table| table.get("projects")) + .and_then(Value::as_table) + .and_then(|projects| projects.get("old")) + .and_then(Value::as_table) + .and_then(|project| project.get("custom")) + .and_then(Value::as_str), + Some("keep") + ); + assert_eq!( + merged + .as_table() + .and_then(|table| table.get("projects")) + .and_then(Value::as_table) + .and_then(|projects| projects.get("C:\\repo")) + .and_then(Value::as_table) + .and_then(|project| project.get("permission_preset")) + .and_then(Value::as_str), + Some("full-access") + ); + } } diff --git a/crates/tui/src/slash_command.rs b/crates/tui/src/slash_command.rs index 78183b0..937ae29 100644 --- a/crates/tui/src/slash_command.rs +++ b/crates/tui/src/slash_command.rs @@ -7,6 +7,7 @@ pub enum SlashCommand { Resume, New, Status, + Permissions, Clear, Onboard, Diff, @@ -23,6 +24,7 @@ impl SlashCommand { SlashCommand::Resume => "resume a saved chat", SlashCommand::New => "start a new chat", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Permissions => "choose what Devo is allowed to do", SlashCommand::Clear => "clear the current transcript", SlashCommand::Onboard => "configure model provider connection", SlashCommand::Diff => "show git diff (including untracked files)", @@ -39,6 +41,7 @@ impl SlashCommand { SlashCommand::Resume => "resume", SlashCommand::New => "new", SlashCommand::Status => "status", + SlashCommand::Permissions => "permissions", SlashCommand::Clear => "clear", SlashCommand::Onboard => "onboard", SlashCommand::Diff => "diff", @@ -76,6 +79,7 @@ impl std::str::FromStr for SlashCommand { "resume" => Ok(Self::Resume), "new" => Ok(Self::New), "status" => Ok(Self::Status), + "permissions" | "approvals" => Ok(Self::Permissions), "clear" => Ok(Self::Clear), "onboard" => Ok(Self::Onboard), "diff" => Ok(Self::Diff), @@ -94,6 +98,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { ("resume", SlashCommand::Resume), ("new", SlashCommand::New), ("status", SlashCommand::Status), + ("permissions", SlashCommand::Permissions), ("clear", SlashCommand::Clear), ("onboard", SlashCommand::Onboard), ("diff", SlashCommand::Diff), diff --git a/crates/tui/src/streaming/commit_tick.rs b/crates/tui/src/streaming/commit_tick.rs index 553f7ce..ac6488f 100644 --- a/crates/tui/src/streaming/commit_tick.rs +++ b/crates/tui/src/streaming/commit_tick.rs @@ -144,7 +144,6 @@ fn apply_commit_tick_plan( } output.all_idle &= is_idle; } - output } diff --git a/crates/tui/src/streaming/controller.rs b/crates/tui/src/streaming/controller.rs index 3996697..4637ad8 100644 --- a/crates/tui/src/streaming/controller.rs +++ b/crates/tui/src/streaming/controller.rs @@ -1,281 +1,350 @@ +//! Streams markdown deltas while retaining source for later transcript reflow. +//! +//! Streaming has two outputs with different lifetimes. The live viewport needs incremental +//! `HistoryCell`s so the user sees progress, while finalized transcript history needs raw markdown +//! source so it can be rendered again after a terminal resize. These controllers keep those outputs +//! tied together: newline-complete source is rendered into queued live cells, and finalization +//! returns the accumulated source to the app for consolidation. +//! +//! Width changes are handled by re-rendering from source and rebuilding only the not-yet-emitted +//! queue. Already emitted rows stay emitted until the app-level transcript reflow rebuilds the full +//! scrollback from finalized cells. + use crate::history_cell::HistoryCell; use crate::history_cell::{self}; -use ratatui::style::Style; -use ratatui::style::Stylize; +use crate::markdown::append_markdown; use ratatui::text::Line; use std::path::Path; +use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use super::StreamState; -/// Controller that manages newline-gated streaming, header emission, and -/// commit animation across streams. -pub(crate) struct StreamController { +/// Shared source-retaining stream state for assistant output. +/// +/// `raw_source` is the markdown source that has crossed a newline boundary and can be rendered +/// deterministically. `rendered_lines` is the current-width render of that source. `enqueued_len` +/// tracks how much of that render has been offered to the commit queue, while `emitted_len` tracks +/// how much has actually reached history cells. Keeping those counters separate lets width changes +/// rebuild pending output without duplicating lines that are already visible. +struct StreamCore { state: StreamState, - finishing_after_drain: bool, - header_emitted: bool, - content_style: Style, + width: Option, + raw_source: String, + rendered_lines: Vec>, + enqueued_len: usize, + emitted_len: usize, + cwd: PathBuf, } -impl StreamController { - /// Create a controller whose markdown renderer shortens local file links relative to `cwd`. - /// - /// The controller snapshots the path into stream state so later commit ticks and finalization - /// render against the same session cwd that was active when streaming started. - pub(crate) fn new(width: Option, cwd: &Path) -> Self { - Self::new_with_style(width, cwd, Style::default()) - } - - pub(crate) fn new_with_style(width: Option, cwd: &Path, content_style: Style) -> Self { +impl StreamCore { + fn new(width: Option, cwd: &Path) -> Self { Self { state: StreamState::new(width, cwd), - finishing_after_drain: false, - header_emitted: false, - content_style, + width, + raw_source: String::with_capacity(1024), + rendered_lines: Vec::with_capacity(64), + enqueued_len: 0, + emitted_len: 0, + cwd: cwd.to_path_buf(), } } - /// Push a delta; if it contains a newline, commit completed lines and start animation. - pub(crate) fn push(&mut self, delta: &str) -> bool { - let state = &mut self.state; + fn push_delta(&mut self, delta: &str) -> bool { if !delta.is_empty() { - state.has_seen_delta = true; + self.state.has_seen_delta = true; } - state.collector.push_delta(delta); - if delta.contains('\n') { - let newly_completed = state.collector.commit_complete_lines(); - if !newly_completed.is_empty() { - state.enqueue(newly_completed); - return true; - } + self.state.collector.push_delta(delta); + + if delta.contains('\n') + && let Some(committed_source) = self.state.collector.commit_complete_source() + { + self.raw_source.push_str(&committed_source); + self.recompute_render(); + return self.sync_queue_to_render(); } + false } - /// Finalize the active stream. Drain and emit now. - pub(crate) fn finalize(&mut self) -> Option> { - // Finalize collector first. - let remaining = { - let state = &mut self.state; - state.collector.finalize_and_drain() - }; - // Collect all output first to avoid emitting headers when there is no content. - let mut out_lines = Vec::new(); + fn finalize_remaining(&mut self) -> Vec> { + let remainder_source = self.state.collector.finalize_and_drain_source(); + if !remainder_source.is_empty() { + self.raw_source.push_str(&remainder_source); + } + + let mut rendered = Vec::new(); + append_markdown( + &self.raw_source, + self.width, + Some(self.cwd.as_path()), + &mut rendered, + ); + if self.emitted_len >= rendered.len() { + Vec::new() + } else { + rendered[self.emitted_len..].to_vec() + } + } + + fn tick(&mut self) -> Vec> { + let step = self.state.step(); + self.emitted_len += step.len(); + step + } + + fn tick_batch(&mut self, max_lines: usize) -> Vec> { + if max_lines == 0 { + return Vec::new(); + } + let step = self.state.drain_n(max_lines); + self.emitted_len += step.len(); + step + } + + fn queued_lines(&self) -> usize { + self.state.queued_len() + } + + fn oldest_queued_age(&self, now: Instant) -> Option { + self.state.oldest_queued_age(now) + } + + fn is_idle(&self) -> bool { + self.state.is_idle() + } + + fn set_width(&mut self, width: Option) { + if self.width == width { + return; + } + + let had_pending_queue = self.state.queued_len() > 0; + self.width = width; + self.state.collector.set_width(width); + if self.raw_source.is_empty() { + return; + } + + self.recompute_render(); + self.emitted_len = self.emitted_len.min(self.rendered_lines.len()); + if had_pending_queue + && self.emitted_len == self.rendered_lines.len() + && self.emitted_len > 0 { - let state = &mut self.state; - if !remaining.is_empty() { - state.enqueue(remaining); - } - let step = state.drain_all(); - out_lines.extend(step); + // If wrapped remainder compresses into fewer lines at the new width, + // keep at least one line un-emitted so pre-resize pending content is + // not skipped permanently. + self.emitted_len -= 1; + } + + self.state.clear_queue(); + if self.emitted_len > 0 && !had_pending_queue { + self.enqueued_len = self.rendered_lines.len(); + return; } + self.rebuild_queue_from_render(); + } + + fn clear_queue(&mut self) { + self.state.clear_queue(); + self.enqueued_len = self.emitted_len; + } - // Cleanup + fn reset(&mut self) { self.state.clear(); - self.finishing_after_drain = false; - self.emit(out_lines) + self.raw_source.clear(); + self.rendered_lines.clear(); + self.enqueued_len = 0; + self.emitted_len = 0; } - /// Step animation: commit at most one queued line and handle end-of-drain cleanup. - pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { - let step = self.state.step(); - (self.emit(step), self.state.is_idle()) + fn live_source(&self) -> String { + let mut source = self.raw_source.clone(); + source.push_str(self.state.collector.uncommitted_source()); + source + } + + fn recompute_render(&mut self) { + self.rendered_lines.clear(); + append_markdown( + &self.raw_source, + self.width, + Some(self.cwd.as_path()), + &mut self.rendered_lines, + ); + } + + /// Append newly rendered lines to the live queue without replaying already queued rows. + /// + /// Width changes can make the rendered line count smaller than the previous queue boundary; in + /// that case the only safe option is rebuilding the queue from `emitted_len`, because slicing + /// from the stale `enqueued_len` would skip pending source. + fn sync_queue_to_render(&mut self) -> bool { + let target_len = self.rendered_lines.len().max(self.emitted_len); + if target_len < self.enqueued_len { + self.rebuild_queue_from_render(); + return self.state.queued_len() > 0; + } + + if target_len == self.enqueued_len { + return false; + } + + self.state + .enqueue(self.rendered_lines[self.enqueued_len..target_len].to_vec()); + self.enqueued_len = target_len; + true + } + + /// Rebuild the pending live queue from the current render and current emitted position. + /// + /// This is used when resize invalidates queued wrapping. It must never enqueue rows before + /// `emitted_len`, because those rows have already been inserted into terminal history. + fn rebuild_queue_from_render(&mut self) { + self.state.clear_queue(); + let target_len = self.rendered_lines.len().max(self.emitted_len); + if self.emitted_len < target_len { + self.state + .enqueue(self.rendered_lines[self.emitted_len..target_len].to_vec()); + } + self.enqueued_len = target_len; + } +} + +/// Controls newline-gated streaming for assistant messages. +/// +/// The controller emits transient `AgentMessageCell`s for live display and returns raw markdown +/// source on `finalize` so the app can replace those transient cells with a source-backed +/// `AgentMarkdownCell`. Callers should use `set_width` on terminal resize; rebuilding the queue +/// from already emitted cells would duplicate output instead of preserving the stream position. +pub(crate) struct StreamController { + core: StreamCore, + header_emitted: bool, +} + +impl StreamController { + /// Create a stream controller that renders markdown relative to the given width and cwd. + /// + /// `width` is the content width available to markdown rendering, not necessarily the full + /// terminal width. Passing a stale width after resize will keep queued live output wrapped for + /// the old viewport until app-level reflow repairs the finalized transcript. + pub(crate) fn new(width: Option, cwd: &Path) -> Self { + Self { + core: StreamCore::new(width, cwd), + header_emitted: false, + } } - /// Step animation: commit at most `max_lines` queued lines. + /// Push a raw model delta and return whether it produced queued complete lines. /// - /// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a - /// very large value can collapse perceived animation into a single jump. + /// Deltas are committed only through newline boundaries. A `false` return can still mean source + /// was buffered; it only means no newly renderable complete line is ready for live emission. + pub(crate) fn push(&mut self, delta: &str) -> bool { + self.core.push_delta(delta) + } + + /// Finish the stream and return the final transient cell plus accumulated markdown source. + pub(crate) fn finalize(&mut self) -> (Option>, Option) { + let remaining = self.core.finalize_remaining(); + if self.core.raw_source.is_empty() { + self.core.reset(); + return (None, None); + } + + let source = std::mem::take(&mut self.core.raw_source); + let out = self.emit(remaining); + self.core.reset(); + (out, Some(source)) + } + + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.core.tick(); + (self.emit(step), self.core.is_idle()) + } + pub(crate) fn on_commit_tick_batch( &mut self, max_lines: usize, ) -> (Option>, bool) { - let step = self.state.drain_n(max_lines.max(1)); - (self.emit(step), self.state.is_idle()) + let step = self.core.tick_batch(max_lines); + (self.emit(step), self.core.is_idle()) } - /// Returns the current number of queued lines waiting to be displayed. pub(crate) fn queued_lines(&self) -> usize { - self.state.queued_len() + self.core.queued_lines() } - /// Returns the age of the oldest queued line. pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { - self.state.oldest_queued_age(now) + self.core.oldest_queued_age(now) } - /// Render the current uncommitted tail that should remain in the live viewport. - pub(crate) fn pending_lines(&self) -> Vec> { - patch_lines_style(self.state.collector.pending_lines(), self.content_style) + pub(crate) fn clear_queue(&mut self) { + self.core.clear_queue(); + } + + pub(crate) fn set_width(&mut self, width: Option) { + self.core.set_width(width); } - /// Render all lines currently visible in the live viewport, including queued committed lines. pub(crate) fn live_lines(&self) -> Vec> { - let mut lines = patch_lines_style(self.state.queued_lines(), self.content_style); - lines.extend(self.pending_lines()); - lines + let source = self.core.live_source(); + if source.is_empty() { + return Vec::new(); + } + let mut rendered = Vec::new(); + append_markdown( + &source, + self.core.width, + Some(self.core.cwd.as_path()), + &mut rendered, + ); + history_cell::collapse_consecutive_blank_lines(rendered) } fn emit(&mut self, lines: Vec>) -> Option> { if lines.is_empty() { return None; } - let lines = patch_lines_style(lines, self.content_style); - let initial_prefix = if self.header_emitted { - " ".into() - } else { - Line::from("• ".cyan()) - }; - let is_stream_continuation = self.header_emitted; - self.header_emitted = true; - Some(Box::new( - history_cell::AgentMessageCell::new_ai_response_with_prefix( - lines, - initial_prefix, - " ", - is_stream_continuation, - ), - )) - } -} - -fn patch_lines_style(mut lines: Vec>, style: Style) -> Vec> { - if style == Style::default() { - return lines; - } - - for line in &mut lines { - line.spans = line - .spans - .drain(..) - .map(|span| span.patch_style(style)) - .collect(); + Some(Box::new(history_cell::AgentMessageCell::new(lines, { + let header_emitted = self.header_emitted; + self.header_emitted = true; + !header_emitted + }))) } - - lines } #[cfg(test)] mod tests { use super::*; - use ratatui::style::Color; - use std::path::PathBuf; + use pretty_assertions::assert_eq; fn test_cwd() -> PathBuf { - // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or - // Windows-specific root semantics into the fixtures. std::env::temp_dir() } - fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec { + fn stream_controller(width: Option) -> StreamController { + StreamController::new(width, &test_cwd()) + } + + fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec { lines .iter() - .map(|l| { - l.spans + .map(|line| { + line.spans .iter() - .map(|s| s.content.clone()) - .collect::>() - .join("") + .map(|span| span.content.clone()) + .collect::() }) .collect() } - #[test] - fn first_committed_stream_chunk_uses_blue_dot_prefix() { - let mut ctrl = StreamController::new(Some(80), &test_cwd()); - - ctrl.push("hello\n"); - let (cell, _idle) = ctrl.on_commit_tick(); - - let cell = cell.expect("expected committed cell"); - let rendered = cell.display_lines(80); - let first_line = rendered.first().expect("expected rendered line"); - let first_span = first_line.spans.first().expect("expected prefix span"); - - assert_eq!(first_span.content.as_ref(), "• "); - assert_eq!(first_span.style.fg, Some(Color::Cyan)); - } - - #[tokio::test] - async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { - let mut ctrl = StreamController::new(/*width*/ None, &test_cwd()); + fn collect_streamed_lines(deltas: &[&str], width: Option) -> Vec { + let mut ctrl = stream_controller(width); let mut lines = Vec::new(); - - // Exact deltas from the session log (section: Loose vs. tight list items) - let deltas = vec![ - "\n\n", - "Loose", - " vs", - ".", - " tight", - " list", - " items", - ":\n", - "1", - ".", - " Tight", - " item", - "\n", - "2", - ".", - " Another", - " tight", - " item", - "\n\n", - "1", - ".", - " Loose", - " item", - " with", - " its", - " own", - " paragraph", - ".\n\n", - " ", - " This", - " paragraph", - " belongs", - " to", - " the", - " same", - " list", - " item", - ".\n\n", - "2", - ".", - " Second", - " loose", - " item", - " with", - " a", - " nested", - " list", - " after", - " a", - " blank", - " line", - ".\n\n", - " ", - " -", - " Nested", - " bullet", - " under", - " a", - " loose", - " item", - "\n", - " ", - " -", - " Another", - " nested", - " bullet", - "\n\n", - ]; - - // Simulate streaming with a commit tick attempt after each delta. - for d in deltas.iter() { - ctrl.push(d); + for delta in deltas { + ctrl.push(delta); while let (Some(cell), idle) = ctrl.on_commit_tick() { lines.extend(cell.transcript_lines(u16::MAX)); if idle { @@ -283,47 +352,122 @@ mod tests { } } } - // Finalize and flush remaining lines now. - if let Some(cell) = ctrl.finalize() { + if let (Some(cell), _source) = ctrl.finalize() { lines.extend(cell.transcript_lines(u16::MAX)); } - - let streamed: Vec<_> = lines_to_plain_strings(&lines) + lines_to_plain_strings(&lines) .into_iter() - // skip • and 2-space indentation - .map(|s| s.chars().skip(2).collect::()) - .collect(); - - // Full render of the same source - let source: String = deltas.iter().copied().collect(); - let mut rendered: Vec> = Vec::new(); - let test_cwd = test_cwd(); - crate::markdown::append_markdown( - &source, - /*width*/ None, - Some(test_cwd.as_path()), - &mut rendered, + .map(|line| line.chars().skip(2).collect::()) + .collect() + } + + #[test] + fn controller_set_width_rebuilds_queued_lines() { + let mut ctrl = stream_controller(Some(120)); + let delta = "This is a long line that should wrap into multiple rows when resized.\n"; + assert!(ctrl.push(delta)); + assert_eq!(ctrl.queued_lines(), 1); + + ctrl.set_width(Some(24)); + let (cell, idle) = ctrl.on_commit_tick_batch(usize::MAX); + let rendered = lines_to_plain_strings( + &cell + .expect("expected resized queued lines") + .transcript_lines(u16::MAX), ); - let rendered_strs = lines_to_plain_strings(&rendered); - - assert_eq!(streamed, rendered_strs); - - // Also assert exact expected plain strings for clarity. - let expected = vec![ - "Loose vs. tight list items:".to_string(), - "".to_string(), - "1. Tight item".to_string(), - "2. Another tight item".to_string(), - "3. Loose item with its own paragraph.".to_string(), - "".to_string(), - " This paragraph belongs to the same list item.".to_string(), - "4. Second loose item with a nested list after a blank line.".to_string(), - " - Nested bullet under a loose item".to_string(), - " - Another nested bullet".to_string(), - ]; + + assert!(idle); + assert!( + rendered.len() > 1, + "expected resized content to occupy multiple lines, got {rendered:?}", + ); + } + + #[test] + fn controller_set_width_no_duplicate_after_emit() { + let mut ctrl = stream_controller(Some(120)); + let line = + "This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n"; + ctrl.push(line); + let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX); + assert!(cell.is_some(), "expected emitted cell"); + assert_eq!(ctrl.queued_lines(), 0); + + ctrl.set_width(Some(24)); + assert_eq!( - streamed, expected, - "expected exact rendered lines for loose/tight section" + ctrl.queued_lines(), + 0, + "already-emitted content must not be re-queued after resize", ); } + + #[test] + fn controller_tick_batch_zero_is_noop() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("line one\n")); + assert_eq!(ctrl.queued_lines(), 1); + + let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0); + assert!(cell.is_none(), "batch size 0 should not emit lines"); + assert!(!idle, "batch size 0 should not drain queued lines"); + assert_eq!( + ctrl.queued_lines(), + 1, + "queue depth should remain unchanged" + ); + } + + #[test] + fn controller_finalize_returns_cell_and_source() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("hello\n")); + let (cell, source) = ctrl.finalize(); + assert!(cell.is_some()); + assert_eq!(source, Some("hello\n".to_string())); + } + + #[test] + fn live_lines_render_full_accumulated_source() { + let mut ctrl = stream_controller(Some(80)); + + assert!(ctrl.push("## Architecture\n\nA. Input pipeline\n\n")); + ctrl.push("TuiEvent"); + let before_emit = lines_to_plain_strings(&ctrl.live_lines()); + + assert_eq!( + before_emit + .iter() + .filter(|line| line.contains("Architecture")) + .count(), + 1, + "live render should include accumulated completed source once: {before_emit:?}", + ); + assert!( + before_emit.iter().any(|line| line.contains("TuiEvent")), + "live render should include the uncommitted tail: {before_emit:?}", + ); + + let _ = ctrl.on_commit_tick_batch(usize::MAX); + let after_emit = lines_to_plain_strings(&ctrl.live_lines()); + + assert_eq!( + after_emit + .iter() + .filter(|line| line.contains("Architecture")) + .count(), + 1, + "active live render should stay source-backed after commit ticks: {after_emit:?}", + ); + assert!( + after_emit.iter().any(|line| line.contains("TuiEvent")), + "active live render should keep the uncommitted tail after queued lines emit: {after_emit:?}", + ); + } + + #[test] + fn simple_lines_stream_in_order() { + let actual = collect_streamed_lines(&["hello\n", "world\n"], Some(80)); + assert_eq!(actual, vec!["hello".to_string(), "world".to_string()]); + } } diff --git a/crates/tui/src/streaming/mod.rs b/crates/tui/src/streaming/mod.rs index e327d52..e2d003e 100644 --- a/crates/tui/src/streaming/mod.rs +++ b/crates/tui/src/streaming/mod.rs @@ -70,12 +70,9 @@ impl StreamState { .map(|queued| queued.line) .collect() } - /// Drains all queued lines from the front of the queue. - pub(crate) fn drain_all(&mut self) -> Vec> { - self.queued_lines - .drain(..) - .map(|queued| queued.line) - .collect() + /// Clears queued lines while keeping collector/turn lifecycle state intact. + pub(crate) fn clear_queue(&mut self) { + self.queued_lines.clear(); } /// Returns whether no lines are queued for commit. pub(crate) fn is_idle(&self) -> bool { diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 2dbdf76..db5cab1 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -10,6 +10,7 @@ use tokio::task::JoinHandle; use devo_core::Model; use devo_core::ModelCatalog; +use devo_core::PermissionPreset; use devo_core::PresetModelCatalog; use devo_core::ProviderWireApi; use devo_core::ReasoningEffort; @@ -21,6 +22,10 @@ use devo_provider::ModelProviderSDK; use devo_provider::anthropic::AnthropicProvider; use devo_provider::openai::OpenAIProvider; use devo_provider::openai::OpenAIResponsesProvider; +use devo_server::ApprovalDecisionPayload; +use devo_server::ApprovalRequestPayload; +use devo_server::ApprovalRespondParams; +use devo_server::CommandExecutionPayload; use devo_server::InputItem; use devo_server::ItemEnvelope; use devo_server::ItemEventPayload; @@ -47,6 +52,7 @@ use devo_server::TurnSteerParams; use crate::app_command::InputHistoryDirection; use crate::events::SessionListEntry; +use crate::events::TextItemKind; use crate::events::TranscriptItem; use crate::events::TranscriptItemKind; use crate::events::WorkerEvent; @@ -56,6 +62,7 @@ struct EnsureSessionOutcome { model: Option, thinking: Option, reasoning_effort: Option, + created: bool, } /// Immutable runtime configuration used to construct the background server client worker. @@ -68,13 +75,18 @@ pub(crate) struct QueryWorkerConfig { pub(crate) server_log_level: Option, /// Initial thinking mode used for new turns. pub(crate) thinking_selection: Option, + /// Permission preset to apply to the server session when it exists. + pub(crate) permission_preset: PermissionPreset, } /// TODO: Should we extract the OperationCommand to the `protocol` crate? Since it can be shareable. /// Commands accepted by the background query worker. enum OperationCommand { /// Submit a new user prompt to the session. - SubmitPrompt(String), + SubmitPrompt { + prompt: String, + approval_policy: Option, + }, /// Update the model used for future turns. /// TODO: Model should be bind at Session Metadata, not turn, indicate to the model utilized to generate /// at next turn. However, we can still bind a model at turn, to indicate what model is utlized generated. @@ -124,6 +136,16 @@ enum OperationCommand { input: Vec, expected_turn_id: TurnId, }, + ApprovalRespond { + session_id: SessionId, + turn_id: TurnId, + approval_id: String, + decision: devo_server::ApprovalDecisionValue, + scope: devo_server::ApprovalScopeValue, + }, + UpdatePermissions { + preset: devo_protocol::PermissionPreset, + }, /// Browse persisted input history via the server/runtime session state. BrowseInputHistory(InputHistoryDirection), /// Stop the worker loop. @@ -154,9 +176,16 @@ impl QueryWorkerHandle { } /// Submits one prompt to the worker. - pub(crate) fn submit_prompt(&self, prompt: String) -> Result<()> { + pub(crate) fn submit_prompt( + &self, + prompt: String, + approval_policy: Option, + ) -> Result<()> { self.command_tx - .send(OperationCommand::SubmitPrompt(prompt)) + .send(OperationCommand::SubmitPrompt { + prompt, + approval_policy, + }) .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } @@ -281,6 +310,31 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } + pub(crate) fn approval_respond( + &self, + session_id: SessionId, + turn_id: TurnId, + approval_id: String, + decision: devo_server::ApprovalDecisionValue, + scope: devo_server::ApprovalScopeValue, + ) -> Result<()> { + self.command_tx + .send(OperationCommand::ApprovalRespond { + session_id, + turn_id, + approval_id, + decision, + scope, + }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + + pub(crate) fn update_permissions(&self, preset: devo_protocol::PermissionPreset) -> Result<()> { + self.command_tx + .send(OperationCommand::UpdatePermissions { preset }) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + pub(crate) fn browse_input_history(&self, direction: InputHistoryDirection) -> Result<()> { self.command_tx .send(OperationCommand::BrowseInputHistory(direction)) @@ -356,6 +410,7 @@ async fn run_worker_inner( let mut session_cwd = config.cwd.clone(); let mut model = config.model; let mut thinking_selection = config.thinking_selection; + let mut permission_preset = config.permission_preset; let mut active_turn_id: Option = None; let mut turn_count = 0usize; let mut total_input_tokens = 0usize; @@ -371,7 +426,10 @@ async fn run_worker_inner( tokio::select! { maybe_command = command_rx.recv() => { match maybe_command { - Some(OperationCommand::SubmitPrompt(prompt)) => { + Some(OperationCommand::SubmitPrompt { + prompt, + approval_policy, + }) => { let session_start = ensure_session_started( &mut client, &config.cwd, @@ -387,13 +445,21 @@ async fn run_worker_inner( .clone() .or(thinking_selection); let active_session_id = session_start.session_id; + if session_start.created { + apply_session_permissions( + &mut client, + active_session_id, + permission_preset, + ) + .await?; + } let start_result = client.turn_start(TurnStartParams { session_id: active_session_id, input: vec![InputItem::Text { text: prompt }], model: Some(model.clone()), thinking: thinking_selection.clone(), sandbox: None, - approval_policy: None, + approval_policy, cwd: None, }).await; match start_result { @@ -954,6 +1020,53 @@ async fn run_worker_inner( } } } + Some(OperationCommand::ApprovalRespond { + session_id, + turn_id, + approval_id, + decision, + scope, + }) => { + if let Err(error) = client + .approval_respond(ApprovalRespondParams { + session_id, + turn_id, + approval_id: approval_id.into(), + decision, + scope, + }) + .await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + turn_count, + total_input_tokens, + total_output_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + Some(OperationCommand::UpdatePermissions { preset }) => { + permission_preset = preset; + let Some(active_session_id) = session_id else { + continue; + }; + if let Err(error) = + apply_session_permissions(&mut client, active_session_id, preset).await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + turn_count, + total_input_tokens, + total_output_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } Some(OperationCommand::BrowseInputHistory(direction)) => { let text = if let Some(active_session_id) = session_id { match client @@ -1042,19 +1155,72 @@ async fn run_worker_inner( latest_completed_agent_message = None; } "item/started" => { - if let ServerEvent::ItemStarted(payload) = event - && matches!(payload.item.item_kind, ItemKind::ToolCall) - && let Ok(payload) = serde_json::from_value::(payload.item.payload) { - let summary = summarize_tool_call(&payload); - let _ = event_tx.send(WorkerEvent::ToolCall { - tool_use_id: payload.tool_call_id, - summary, + if let ServerEvent::ItemStarted(payload) = event { + tracing::debug!( + item_id = %payload.item.item_id, + item_kind = ?payload.item.item_kind, + "server item started" + ); + match payload.item.item_kind { + ItemKind::AgentMessage => { + let _ = event_tx.send(WorkerEvent::TextItemStarted { + item_id: payload.item.item_id, + kind: TextItemKind::Assistant, }); } + ItemKind::Reasoning => { + let _ = event_tx.send(WorkerEvent::TextItemStarted { + item_id: payload.item.item_id, + kind: TextItemKind::Reasoning, + }); + } + ItemKind::CommandExecution => { + if let Ok(payload) = + serde_json::from_value::( + payload.item.payload, + ) + { + let _ = event_tx.send(WorkerEvent::ToolCall { + tool_use_id: payload.tool_call_id, + summary: payload.command, + }); + } + } + ItemKind::ToolCall => { + if let Ok(payload) = + serde_json::from_value::( + payload.item.payload, + ) + { + let summary = summarize_tool_call(&payload); + let _ = event_tx.send(WorkerEvent::ToolCall { + tool_use_id: payload.tool_call_id, + summary, + }); + } + } + _ => {} + } } + } "item/agentMessage/delta" => { if let ServerEvent::ItemDelta { payload, .. } = event { - let _ = event_tx.send(WorkerEvent::TextDelta(payload.delta)); + if let Some(item_id) = payload.context.item_id { + tracing::debug!( + item_id = %item_id, + delta_len = payload.delta.len(), + stream_index = ?payload.stream_index, + channel = ?payload.channel, + "server assistant delta" + ); + let _ = event_tx.send(WorkerEvent::TextItemDelta { + item_id, + kind: TextItemKind::Assistant, + delta: payload.delta, + }); + } else { + let _ = event_tx.send(WorkerEvent::TextDelta(payload.delta)); + } } } "item/commandExecution/outputDelta" => { @@ -1080,11 +1246,31 @@ async fn run_worker_inner( } "item/reasoning/textDelta" | "item/reasoning/summaryTextDelta" => { if let ServerEvent::ItemDelta { payload, .. } = event { - let _ = event_tx.send(WorkerEvent::ReasoningDelta(payload.delta)); + if let Some(item_id) = payload.context.item_id { + tracing::debug!( + item_id = %item_id, + delta_len = payload.delta.len(), + stream_index = ?payload.stream_index, + channel = ?payload.channel, + "server reasoning delta" + ); + let _ = event_tx.send(WorkerEvent::TextItemDelta { + item_id, + kind: TextItemKind::Reasoning, + delta: payload.delta, + }); + } else { + let _ = event_tx.send(WorkerEvent::ReasoningDelta(payload.delta)); + } } } "item/completed" => { if let ServerEvent::ItemCompleted(payload) = event { + tracing::debug!( + item_id = %payload.item.item_id, + item_kind = ?payload.item.item_kind, + "server item completed" + ); if let Some(text) = completed_agent_message_text(&payload) { latest_completed_agent_message = Some(text); } @@ -1095,6 +1281,11 @@ async fn run_worker_inner( } "turn/completed" => { if let ServerEvent::TurnCompleted(payload) = event { + tracing::debug!( + turn_id = %payload.turn.turn_id, + status = ?payload.turn.status, + "server turn completed" + ); active_turn_id = None; let completed = payload.turn.status == TurnStatus::Completed || payload.turn.status == TurnStatus::Interrupted; @@ -1248,6 +1439,7 @@ async fn ensure_session_started( model: Some(model.to_string()), thinking: None, reasoning_effort: None, + created: false, }); } @@ -1265,9 +1457,24 @@ async fn ensure_session_started( model: session.session.model, thinking: session.session.thinking, reasoning_effort: session.session.reasoning_effort, + created: true, }) } +async fn apply_session_permissions( + client: &mut StdioServerClient, + session_id: SessionId, + preset: PermissionPreset, +) -> Result<()> { + client + .session_permissions_update(devo_server::SessionPermissionsUpdateParams { + session_id, + preset, + }) + .await?; + Ok(()) +} + async fn spawn_client(cwd: &Path, server_log_level: Option) -> Result { let program = std::env::current_exe().context("resolve current executable for server child")?; StdioServerClient::spawn(StdioServerClientConfig { @@ -1334,6 +1541,7 @@ fn completed_agent_message_text(payload: &ItemEventPayload) -> Option { fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSender) { match payload.item { ItemEnvelope { + item_id, item_kind: ItemKind::AgentMessage, payload, .. @@ -1345,10 +1553,20 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe .filter(|text| !text.is_empty()) .map(ToOwned::to_owned); if let Some(text) = text { - let _ = event_tx.send(WorkerEvent::AssistantMessageCompleted(text)); + tracing::debug!( + item_id = %item_id, + final_text_len = text.len(), + "emitting assistant item completion" + ); + let _ = event_tx.send(WorkerEvent::TextItemCompleted { + item_id, + kind: TextItemKind::Assistant, + final_text: text, + }); } } ItemEnvelope { + item_id, item_kind: ItemKind::Reasoning, payload, .. @@ -1360,7 +1578,16 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe .filter(|text| !text.is_empty()) .map(ToOwned::to_owned); if let Some(text) = text { - let _ = event_tx.send(WorkerEvent::ReasoningCompleted(text)); + tracing::debug!( + item_id = %item_id, + final_text_len = text.len(), + "emitting reasoning item completion" + ); + let _ = event_tx.send(WorkerEvent::TextItemCompleted { + item_id, + kind: TextItemKind::Reasoning, + final_text: text, + }); } } ItemEnvelope { @@ -1393,6 +1620,64 @@ fn handle_completed_item(payload: ItemEventPayload, event_tx: &mpsc::UnboundedSe truncated: false, }); } + ItemEnvelope { + item_kind: ItemKind::CommandExecution, + payload, + .. + } => { + let Ok(payload) = serde_json::from_value::(payload) else { + return; + }; + let _ = event_tx.send(WorkerEvent::ToolResult { + tool_use_id: payload.tool_call_id, + title: payload.command, + preview: payload + .output + .as_ref() + .map(render_json_value_text) + .unwrap_or_default(), + is_error: payload.is_error, + truncated: false, + }); + } + ItemEnvelope { + item_kind: ItemKind::ApprovalRequest, + payload, + .. + } => { + let Ok(payload) = serde_json::from_value::(payload) else { + return; + }; + let Some(turn_id) = payload.request.turn_id else { + return; + }; + let _ = event_tx.send(WorkerEvent::ApprovalRequest { + session_id: payload.request.session_id, + turn_id, + approval_id: payload.approval_id.to_string(), + action_summary: payload.action_summary, + justification: payload.justification, + resource: payload.resource, + available_scopes: payload.available_scopes, + path: payload.path, + host: payload.host, + target: payload.target, + }); + } + ItemEnvelope { + item_kind: ItemKind::ApprovalDecision, + payload, + .. + } => { + let Ok(payload) = serde_json::from_value::(payload) else { + return; + }; + let _ = event_tx.send(WorkerEvent::ApprovalDecision { + approval_id: payload.approval_id.to_string(), + decision: payload.decision, + scope: payload.scope, + }); + } _ => {} } } @@ -1450,6 +1735,7 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { SessionHistoryItemKind::Reasoning => TranscriptItemKind::Reasoning, SessionHistoryItemKind::ToolCall => TranscriptItemKind::ToolCall, SessionHistoryItemKind::ToolResult => TranscriptItemKind::ToolResult, + SessionHistoryItemKind::CommandExecution => TranscriptItemKind::ToolResult, SessionHistoryItemKind::Error => TranscriptItemKind::Error, SessionHistoryItemKind::TurnSummary => TranscriptItemKind::TurnSummary, }; @@ -1458,6 +1744,9 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { SessionHistoryItemKind::ToolResult => { TranscriptItem::restored_tool_result(item.title.clone(), item.body.clone()) } + SessionHistoryItemKind::CommandExecution => { + TranscriptItem::restored_tool_result(item.title.clone(), item.body.clone()) + } SessionHistoryItemKind::Error => { TranscriptItem::tool_error(item.title.clone(), item.body.clone()) } @@ -1982,6 +2271,22 @@ mod tests { ); } + #[test] + fn project_history_restores_command_execution_items() { + let items = vec![SessionHistoryItem { + tool_call_id: Some("call-1".to_string()), + kind: SessionHistoryItemKind::CommandExecution, + title: "cargo test".to_string(), + body: "ok".to_string(), + duration_ms: None, + }]; + + assert_eq!( + project_history_items(&items), + vec![TranscriptItem::restored_tool_result("cargo test", "ok")] + ); + } + #[test] fn project_history_preserves_reasoning_items() { let items = vec![SessionHistoryItem { diff --git a/docs/spec-app-config.md b/docs/spec-app-config.md index a85b6d9..543265e 100644 --- a/docs/spec-app-config.md +++ b/docs/spec-app-config.md @@ -41,6 +41,13 @@ pub struct AppConfig { pub skills: SkillsConfig, pub updates: UpdatesConfig, pub project_root_markers: Vec, + pub projects: BTreeMap, +} +``` + +```rust +pub struct ProjectConfig { + pub permission_preset: Option, } ``` @@ -194,6 +201,23 @@ check_interval_hours = 24 These settings control whether user-facing CLI commands check GitHub Releases for a newer `devo` version and how often a fresh network request is allowed. +## Project Config + +The TUI stores `/permissions` selections in the user-level config so the preset +can be changed before any session exists. Project entries are keyed by git +repository root, falling back to the current working directory when no git root +is found: + +```toml +[projects."C:\\Users\\me\\repo"] +permission_preset = "default" +``` + +Supported preset values are `read-only`, `default`, `auto-review`, and +`full-access`. The `projects` table is intentionally extensible for future +project-scoped settings. New server sessions are initialized from the remembered +permission preset after the first prompt creates the session. + ## File Locations - user config: `DEVO_HOME/config.toml` diff --git a/docs/spec-safety.md b/docs/spec-safety.md index 7b127c9..5239606 100644 --- a/docs/spec-safety.md +++ b/docs/spec-safety.md @@ -142,6 +142,22 @@ Policy modes supported by the overview: - `StaticPolicy` - `ModelGuidedPolicy` +Runtime permission presets exposed by `/permissions`: + +- `ReadOnly`: read workspace files without approval; edits, shell commands, and network ask. +- `Default`: read and edit files inside the workspace and run shell commands; network and writes outside the workspace ask. +- `AutoReview`: same base policy as `Default`, but eligible `Ask` decisions are reviewed by the automatic reviewer before interrupting the user. +- `FullAccess`: allow tool requests without approval. This mode is intended for trusted environments only. + +Preset rules: + +- The active runtime permission profile is the source of truth for tool execution decisions. +- Preset changes must clear cached approvals created under the previous profile. +- `AutoReview` is a reviewer routing mode, not a broader sandbox mode. +- If the reviewer approves, execution continues and an approval-decision audit item is recorded. +- If the reviewer denies, execution is blocked and an approval-decision audit item is recorded. +- If the reviewer is uncertain, unavailable, or returns invalid output, the runtime must fall back to user approval. + Implementation note: - `ModelGuidedPolicy` must use a configurable model-selection policy rather than implicitly binding to either the active main model or a fixed classifier model. diff --git a/docs/spec-server-api.md b/docs/spec-server-api.md index 1f610cd..2e4a0b6 100644 --- a/docs/spec-server-api.md +++ b/docs/spec-server-api.md @@ -292,6 +292,34 @@ Rules: ## Approval Methods +### `session/permissions/update` + +Request fields: + +- `sessionId` +- `preset` + +Preset values: + +- `read-only` +- `default` +- `auto-review` +- `full-access` + +Response fields: + +- `sessionId` +- `preset` +- `reviewer` + +Rules: + +- The selected preset updates the live session permission profile used by subsequent tool executions. +- Updating the preset clears turn-scoped and session-scoped approval caches so grants from a previous mode cannot silently widen the new mode. +- `auto-review` uses the same base execution policy as `default`, but approval requests that still require escalation are first routed through the automatic reviewer. +- The automatic reviewer may approve or deny clear cases. Invalid, failed, or uncertain reviewer output must fall back to `approval/respond`. +- The current implementation updates runtime session state; durable cross-process persistence of the selected preset is a later persistence concern. + ### `approval/respond` Request fields: @@ -316,6 +344,7 @@ Scope values: - `path_prefix` - `host` - `tool` +- `command_prefix` ## Optional Event Subscription Method diff --git a/docs/spec-tools.md b/docs/spec-tools.md index 8858958..bfbd45d 100644 --- a/docs/spec-tools.md +++ b/docs/spec-tools.md @@ -256,6 +256,8 @@ Rules: - `timeout_ms` must be clamped to an app-configured maximum - environment overrides are additive and must be filtered by the safety layer - escalation request fields are metadata for approval and execution policy, not free authorization +- command-prefix approvals are only offered for simple commands that can be parsed into a stable executable/subcommand prefix +- commands containing shell control operators, redirection, command substitution, environment-variable substitution, wildcard patterns, or other ambiguous shell features must not be cached by command prefix; they may still be approved for a narrower once/turn/session/tool scope ### Execution Contract