docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bearer prefix length shared across crate boundaries.
|
||||
pub const SENT_BEARER_PREFIX_LEN: usize = 12;
|
||||
|
||||
/// Which tool endpoint produced the 401.
|
||||
@@ -19,19 +18,15 @@ impl ToolConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 401 attribution callback. Shell wires this to emit telemetry.
|
||||
/// Implemented by the shell, which turns these events into telemetry.
|
||||
pub trait Auth401AttributionCallback: Send + Sync + std::fmt::Debug {
|
||||
/// `sent_bearer_prefix` is truncated to [`SENT_BEARER_PREFIX_LEN`]
|
||||
/// before crossing this boundary. `None` = no bearer was sent.
|
||||
fn record_401(&self, consumer: ToolConsumer, sent_bearer_prefix: Option<&str>);
|
||||
}
|
||||
|
||||
/// Shared, cheap-to-clone alias for the attribution callback.
|
||||
pub type SharedAttributionCallback = Arc<dyn Auth401AttributionCallback>;
|
||||
|
||||
/// Record a 401 attribution event if a callback is wired. Truncates
|
||||
/// the bearer to [`SENT_BEARER_PREFIX_LEN`] before crossing the
|
||||
/// trait boundary.
|
||||
pub(crate) fn emit_401(
|
||||
callback: Option<&SharedAttributionCallback>,
|
||||
consumer: ToolConsumer,
|
||||
@@ -43,14 +38,11 @@ pub(crate) fn emit_401(
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a bearer string to the first [`SENT_BEARER_PREFIX_LEN`]
|
||||
/// characters. Used by tool clients before passing the bearer across
|
||||
/// the [`Auth401AttributionCallback`] boundary.
|
||||
/// Truncate a bearer to [`SENT_BEARER_PREFIX_LEN`] bytes.
|
||||
///
|
||||
/// Bearer tokens are ASCII (per the `Authorization` header grammar)
|
||||
/// so the byte index is always a char boundary; this function uses
|
||||
/// `String::truncate` which would otherwise panic on a non-boundary
|
||||
/// cut.
|
||||
/// `String::truncate` panics when the cut is not a char boundary; bearer
|
||||
/// tokens are ASCII per the `Authorization` header grammar, so every byte
|
||||
/// index is a boundary.
|
||||
pub(crate) fn truncate_to_prefix(mut bearer: String) -> String {
|
||||
bearer.truncate(SENT_BEARER_PREFIX_LEN.min(bearer.len()));
|
||||
bearer
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! ToolBridge: adapter that wraps `kigi-tools`'s `ToolRegistry` and
|
||||
//! exposes it through a session layer.
|
||||
//!
|
||||
//! The bridge:
|
||||
//! 1. Owns a `ToolRegistry` with all built-in tools registered
|
||||
//! 2. Dispatches tool calls via `call_new_tool()`
|
||||
//! 3. Manages tool definitions, enable/disable, name overrides
|
||||
//! Adapter that owns a finalized `ToolRegistry` and exposes tool dispatch,
|
||||
//! definitions and tracker state to the session layer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -23,16 +18,11 @@ use crate::types::resources::{OwnerSessionId, State, Terminal};
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use crate::types::tool::ToolKind;
|
||||
|
||||
/// Result of executing a tool through the bridge.
|
||||
///
|
||||
/// Carries all the data the session needs to:
|
||||
/// 1. Send ACP notifications (from `output`)
|
||||
/// 2. Build the model prompt (from `prompt_text`)
|
||||
#[derive(Debug)]
|
||||
pub struct ToolBridgeResult {
|
||||
/// Clean tool output — for JSON serialization, ACP conversion, hunk tracking.
|
||||
pub output: ToolOutput,
|
||||
/// Prompt-ready text — with system reminders appended.
|
||||
/// Same output with system reminders appended, for the model prompt.
|
||||
pub prompt_text: String,
|
||||
}
|
||||
|
||||
@@ -45,17 +35,14 @@ impl From<ToolRunResult> for ToolBridgeResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges the `ToolRegistry` into a session layer.
|
||||
///
|
||||
/// Owns the registry and dispatches tool calls via `call_new_tool()`.
|
||||
/// All state lives in `Resources` on the registry — no separate `ToolState`.
|
||||
/// All tool state lives in `Resources` on the registry — there is no separate
|
||||
/// `ToolState`.
|
||||
///
|
||||
/// # Cancellation Safety
|
||||
///
|
||||
/// The `terminal` field is stored separately from the registry lock to enable
|
||||
/// cancellation during tool execution. When a bash command is running, the
|
||||
/// registry lock is held by `call()`. If the user cancels, `kill_foreground_commands()`
|
||||
/// needs to access the terminal without blocking on the lock.
|
||||
/// `terminal` is held here as well as in the registry: while a bash command
|
||||
/// runs, `call()` holds the registry lock, and `kill_foreground_commands()`
|
||||
/// must reach the terminal without blocking on it.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolBridge {
|
||||
registry: Arc<FinalizedToolset>,
|
||||
@@ -98,15 +85,10 @@ impl ToolBridge {
|
||||
self.registry.tool_definitions()
|
||||
}
|
||||
|
||||
/// Returns the client-facing name of the tool registered with the given
|
||||
/// `ToolKind`, if any. Looks up the kind->name map populated by
|
||||
/// `FinalizedToolset` from each tool's `kind()`. Useful for "does this
|
||||
/// agent have a way to do X?" checks where the X is identified by kind
|
||||
/// rather than by namespaced id.
|
||||
///
|
||||
/// Example: `tool_for_kind(ToolKind::BackgroundTaskAction)` returns
|
||||
/// `Some("get_task_output")` for the kigi agent and `None` for
|
||||
/// agents that do not register a tool of that kind.
|
||||
/// Client-facing name of the tool registered with the given `ToolKind`,
|
||||
/// for "does this agent have a way to do X?" checks where X is identified
|
||||
/// by kind rather than by namespaced id. `None` when no registered tool
|
||||
/// has that kind.
|
||||
pub async fn tool_for_kind(&self, kind: ToolKind) -> Option<String> {
|
||||
self.registry
|
||||
.resources
|
||||
@@ -116,26 +98,21 @@ impl ToolBridge {
|
||||
.and_then(|r| r.tool_for_kind(kind).map(str::to_string))
|
||||
}
|
||||
|
||||
/// [`ToolKind`] for a registered tool by client-facing name, or
|
||||
/// `None` for unknown names. Sync — uses the registry's
|
||||
/// `RwLock::read`.
|
||||
/// [`ToolKind`] for a registered tool by client-facing name, or `None` for
|
||||
/// unknown names. Name matching is exact and case-sensitive.
|
||||
pub fn tool_kind(&self, tool_name: &str) -> Option<ToolKind> {
|
||||
self.registry.get_tool_metadata(tool_name).map(|m| m.kind())
|
||||
}
|
||||
|
||||
/// Get only built-in tool definitions (exclude MCP tools).
|
||||
pub async fn tool_definitions_builtins_only(&self) -> Vec<ToolDefinition> {
|
||||
self.registry.tool_definitions_builtins_only()
|
||||
}
|
||||
|
||||
/// Render a prompt template through [`TemplateRenderer`] with extra
|
||||
/// agent-specific context fields.
|
||||
/// The template may mix `${{ tools.by_kind.* }}`, resolved from the
|
||||
/// finalized registry, with caller-provided fields such as
|
||||
/// `${{ os_name }}` supplied in `placeholders`.
|
||||
///
|
||||
/// The template can use both `${{ tools.by_kind.* }}` (resolved from
|
||||
/// the finalized tool registry) and caller-provided fields like
|
||||
/// `${{ os_name }}`, `${{ memory_enabled }}`, etc.
|
||||
///
|
||||
/// Returns `None` if the renderer is not yet available.
|
||||
/// `None` when the renderer is not yet available.
|
||||
pub async fn render_prompt(
|
||||
&self,
|
||||
template: &str,
|
||||
@@ -181,11 +158,6 @@ impl ToolBridge {
|
||||
self.registry.unregister_tool_by_name(name)
|
||||
}
|
||||
|
||||
/// Access the underlying `FinalizedToolset`.
|
||||
///
|
||||
/// Used by `WorkspaceOps::bind_local_session` to install the agent's
|
||||
/// toolset on the workspace session so local-mode tool calls dispatch
|
||||
/// through the workspace.
|
||||
pub fn toolset(&self) -> Arc<FinalizedToolset> {
|
||||
Arc::clone(&self.registry)
|
||||
}
|
||||
@@ -211,10 +183,8 @@ impl ToolBridge {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Seed the AGENTS.md tracker.
|
||||
///
|
||||
/// `compat` gates which rules dirs and agent filenames runtime discovery
|
||||
/// scans. Defaults to all-on at the caller for historical behavior.
|
||||
/// scans; callers default it to all-on.
|
||||
pub async fn seed_agents_md(
|
||||
&self,
|
||||
initial_paths: Vec<std::path::PathBuf>,
|
||||
@@ -233,10 +203,8 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore announced skill names from persisted state.
|
||||
///
|
||||
/// Must be called BEFORE `seed_skill_discovery()` so that `seed()`
|
||||
/// sees non-empty `announced_names` and skips the BaselineChange pending.
|
||||
/// Must run BEFORE `seed_skill_discovery()` so that `seed()` sees
|
||||
/// non-empty `announced_names` and skips the BaselineChange pending.
|
||||
pub async fn restore_announced_skill_names(&self, names: std::collections::HashSet<String>) {
|
||||
let registry = &*self.registry;
|
||||
let mut res = registry.resources.lock().await;
|
||||
@@ -244,7 +212,6 @@ impl ToolBridge {
|
||||
tracker.restore_announced_names(names);
|
||||
}
|
||||
|
||||
/// Get the current set of announced skill names (for persistence).
|
||||
pub async fn get_announced_skill_names(&self) -> std::collections::HashSet<String> {
|
||||
let registry = &*self.registry;
|
||||
let res = registry.resources.lock().await;
|
||||
@@ -264,13 +231,12 @@ impl ToolBridge {
|
||||
.and_then(|t| t.listing_snapshot())
|
||||
}
|
||||
|
||||
/// Seed the SkillDiscoveryTracker with session context and startup skills.
|
||||
/// Must run at session start so the `SkillDiscoveryReminder` can discover
|
||||
/// skills in subdirectories.
|
||||
///
|
||||
/// Must be called at session start so the `SkillDiscoveryReminder` can
|
||||
/// discover skills in subdirectories.
|
||||
/// `display_cwd`: If set (forked sessions), skill paths in
|
||||
/// model-visible announcements are rewritten from real cwd to this
|
||||
/// value. Runtime invocation uses the real path.
|
||||
/// `display_cwd`: when set (forked sessions), skill paths in model-visible
|
||||
/// announcements are rewritten from the real cwd to this value. Runtime
|
||||
/// invocation still uses the real path.
|
||||
pub async fn seed_skill_discovery(
|
||||
&self,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
@@ -283,8 +249,8 @@ impl ToolBridge {
|
||||
) {
|
||||
let registry = &*self.registry;
|
||||
let mut res = registry.resources.lock().await;
|
||||
// Resolve client-facing tool names from the template renderer
|
||||
// so listing headers and descriptions use the correct (possibly randomized) names.
|
||||
// Listing headers and descriptions must use the client-facing names,
|
||||
// which may be randomized per session.
|
||||
let renderer = res.get::<TemplateRenderer>();
|
||||
let skill_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.skill }}").ok());
|
||||
let read_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.read }}").ok());
|
||||
@@ -306,10 +272,8 @@ impl ToolBridge {
|
||||
);
|
||||
}
|
||||
|
||||
/// Enable XML formatting for mid-session skill announcements.
|
||||
///
|
||||
/// When set, `take_pending()` produces `<agent_skill>` XML rows instead of
|
||||
/// markdown, matching the startup `<agent_skills>` preamble format.
|
||||
/// When enabled, `take_pending()` produces `<agent_skill>` XML rows
|
||||
/// instead of markdown, matching the startup `<agent_skills>` preamble.
|
||||
pub async fn set_skill_listing_xml_format(&self, enabled: bool) {
|
||||
let registry = &*self.registry;
|
||||
let mut res = registry.resources.lock().await;
|
||||
@@ -341,8 +305,8 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear `announced_names` and `checked_dirs` so skills get re-announced
|
||||
/// and re-discovered after compaction.
|
||||
/// Clears `announced_names` and `checked_dirs` so skills are re-announced
|
||||
/// and re-discovered after compaction drops them from the conversation.
|
||||
pub async fn on_skill_discovery_compaction(&self) {
|
||||
let registry = &*self.registry;
|
||||
let mut res = registry.resources.lock().await;
|
||||
@@ -352,8 +316,8 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Full reset of skill discovery state for /clear.
|
||||
/// Startup baseline is preserved; a pending reconciliation is queued.
|
||||
/// Full reset of skill discovery state for /clear: the startup baseline
|
||||
/// survives and a pending reconciliation is queued.
|
||||
pub async fn on_skill_discovery_clear(&self) {
|
||||
let registry = &*self.registry;
|
||||
let mut res = registry.resources.lock().await;
|
||||
@@ -363,8 +327,8 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the startup baseline (plugin reload).
|
||||
/// Dynamic discoveries are preserved; a pending reconciliation is queued.
|
||||
/// Replace the startup baseline on plugin reload: dynamic discoveries
|
||||
/// survive and a pending reconciliation is queued.
|
||||
pub async fn update_skill_baseline(
|
||||
&self,
|
||||
new_skills: Vec<crate::implementations::skills::types::SkillInfo>,
|
||||
@@ -377,17 +341,12 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply any pending skill updates.
|
||||
/// Applies a pending change (discovery, baseline update, /clear) by
|
||||
/// writing the runtime projection into `AvailableSkills` and handing back
|
||||
/// the conversation/UI side-effects for the session to execute:
|
||||
/// system-reminder injection, slash command refresh, prompt finalization.
|
||||
///
|
||||
/// If the tracker has a pending change (discovery, baseline update, /clear),
|
||||
/// this method:
|
||||
/// 1. Computes runtime and display projections internally.
|
||||
/// 2. Writes the runtime projection into `AvailableSkills` in Resources.
|
||||
/// 3. Returns `SkillUpdateEffects` with conversation/UI side-effects
|
||||
/// for the session to execute (system-reminder injection, slash
|
||||
/// command refresh, prompt finalization).
|
||||
///
|
||||
/// Returns `None` if nothing changed.
|
||||
/// `None` when nothing is pending.
|
||||
pub async fn apply_pending_skill_update(
|
||||
&self,
|
||||
) -> Option<crate::types::skill_discovery_tracker::SkillUpdateEffects> {
|
||||
@@ -396,17 +355,16 @@ impl ToolBridge {
|
||||
let tracker = res.get_mut::<crate::types::skill_discovery_tracker::SkillManager>()?;
|
||||
let (runtime_skills, effects) = tracker.take_pending()?;
|
||||
|
||||
// Write the runtime projection directly -- the shell never sees this.
|
||||
// The runtime projection stays inside Resources; the shell only ever
|
||||
// sees the effects returned below.
|
||||
res.insert(crate::types::resources::AvailableSkills(runtime_skills));
|
||||
|
||||
Some(effects)
|
||||
}
|
||||
|
||||
/// Get the current display-deduped skill list for slash commands.
|
||||
///
|
||||
/// Returns the combined (startup + discovered) list with canonical-path
|
||||
/// and name dedup applied. This is the authoritative source for slash
|
||||
/// command advertisement — PromptContext is NOT used.
|
||||
/// Combined startup + discovered skills with canonical-path and name
|
||||
/// dedup applied. Authoritative source for slash command advertisement —
|
||||
/// `PromptContext` is NOT used for it.
|
||||
pub async fn slash_skills(&self) -> Vec<crate::implementations::skills::types::SkillInfo> {
|
||||
let registry = &*self.registry;
|
||||
let res = registry.resources.lock().await;
|
||||
@@ -415,7 +373,6 @@ impl ToolBridge {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get the paths that have been reminded about.
|
||||
pub async fn agents_md_reminded_paths(&self) -> std::collections::HashSet<std::path::PathBuf> {
|
||||
let registry = &*self.registry;
|
||||
let result;
|
||||
@@ -431,11 +388,9 @@ impl ToolBridge {
|
||||
result
|
||||
}
|
||||
|
||||
/// Set the stable display path for forked sessions.
|
||||
///
|
||||
/// Inserts [`DisplayCwd`] into the tool registry's [`Resources`] so that
|
||||
/// tools can use [`resolve_model_path`] and [`display_cwd_or_cwd`] to
|
||||
/// rewrite model-provided paths and format output paths correctly.
|
||||
/// Stable display path for forked sessions. Tools read the inserted
|
||||
/// [`DisplayCwd`] through `resolve_model_path` / `display_cwd_or_cwd` to
|
||||
/// rewrite model-provided paths and format output paths.
|
||||
pub async fn set_display_cwd(&self, display_cwd: std::path::PathBuf) {
|
||||
let registry = &*self.registry;
|
||||
registry
|
||||
@@ -445,8 +400,7 @@ impl ToolBridge {
|
||||
.insert(crate::types::resources::DisplayCwd(display_cwd));
|
||||
}
|
||||
|
||||
/// List all known background tasks from the terminal backend.
|
||||
/// Used for context compaction to include task state in summaries.
|
||||
/// Feeds context compaction, which folds task state into summaries.
|
||||
pub async fn list_background_tasks(&self) -> Vec<crate::computer::types::TaskSnapshot> {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
terminal.list_tasks().await
|
||||
@@ -455,14 +409,12 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill all foreground terminal commands.
|
||||
pub async fn kill_foreground_commands(&self) {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
terminal.kill_foreground_commands().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill all running foreground processes owned by a specific session.
|
||||
pub async fn kill_foreground_commands_by_owner(&self, owner_session_id: &str) {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
terminal
|
||||
@@ -471,15 +423,14 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill all running background tasks.
|
||||
pub async fn kill_all_background_tasks(&self) {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
terminal.kill_all_background_tasks().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill all running background tasks owned by a specific session.
|
||||
/// Used during subagent teardown on a shared terminal backend.
|
||||
/// Owner-scoped so subagent teardown spares the tasks of other sessions
|
||||
/// sharing the terminal backend.
|
||||
pub async fn kill_all_background_tasks_by_owner(&self, owner_session_id: &str) {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
terminal
|
||||
@@ -488,7 +439,6 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent notification handles for tasks owned by `old_owner_session_id`.
|
||||
pub async fn reparent_notifications(
|
||||
&self,
|
||||
old_owner_session_id: &str,
|
||||
@@ -496,7 +446,8 @@ impl ToolBridge {
|
||||
new_handle: crate::notification::types::ToolNotificationHandle,
|
||||
) {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
// Weak anchored by this bridge's backend `Arc` (lives as long as the session).
|
||||
// Weak, so the reparented notifications cannot keep the backend
|
||||
// alive past the session that owns this `Arc`.
|
||||
let backend_weak = std::sync::Arc::downgrade(terminal);
|
||||
terminal
|
||||
.reparent_notifications(
|
||||
@@ -509,27 +460,19 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a typed resource from the registry.
|
||||
///
|
||||
/// Returns `None` if the resource type has never been inserted.
|
||||
/// The resource is cloned so no lock is held after this returns.
|
||||
/// `None` if the resource type has never been inserted. The resource is
|
||||
/// cloned so no lock is held once this returns.
|
||||
pub async fn read_resource<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
|
||||
self.registry.resources.lock().await.get::<T>().cloned()
|
||||
}
|
||||
/// Get the shared resources handle for direct access.
|
||||
/// Used by the skill reconciliation helper which needs to update
|
||||
/// `AvailableSkills` in the Resources directly.
|
||||
pub async fn shared_resources(&self) -> crate::types::resources::SharedResources {
|
||||
self.registry.resources.clone()
|
||||
}
|
||||
|
||||
/// Insert a typed resource into the registry's `Resources`.
|
||||
/// Used by the host session to inject `ToolIndex` for search_tool.
|
||||
pub async fn update_resource<T: Send + Sync + 'static>(&self, resource: T) {
|
||||
let _ = self.registry.update_resource(resource).await;
|
||||
}
|
||||
|
||||
/// Kill any background task
|
||||
pub async fn kill_background_task(
|
||||
&self,
|
||||
task_id: &str,
|
||||
@@ -571,7 +514,6 @@ impl ToolBridge {
|
||||
})
|
||||
}
|
||||
|
||||
/// Move a foreground command to background by tool_call_id.
|
||||
/// Returns `true` if a matching foreground process was found and unblocked.
|
||||
pub async fn background_foreground_command(&self, tool_call_id: &str) -> bool {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
@@ -581,7 +523,8 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the output of all terminal tasks which are managed by the tool bridge
|
||||
/// `None` when this bridge has no terminal backend at all, as opposed to
|
||||
/// an empty task list.
|
||||
pub async fn list_tasks(&self) -> Option<Vec<TaskSnapshot>> {
|
||||
if let Some(terminal) = &self.terminal {
|
||||
Some(terminal.list_tasks().await)
|
||||
@@ -590,9 +533,9 @@ impl ToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain newly-completed bash background tasks not yet reported.
|
||||
/// Marks returned tasks in [`ReportedTaskCompletions`] to prevent
|
||||
/// duplicate reminders from [`TaskCompletionReminder`].
|
||||
/// Drain newly-completed bash background tasks not yet reported. Returned
|
||||
/// tasks are marked in [`ReportedTaskCompletions`] so
|
||||
/// [`TaskCompletionReminder`] does not report them a second time.
|
||||
pub async fn drain_between_turn_bash_completions(&self) -> Vec<TaskSnapshot> {
|
||||
let tasks = match self.list_tasks().await {
|
||||
Some(t) => t,
|
||||
@@ -610,11 +553,9 @@ impl ToolBridge {
|
||||
|
||||
let mut res = self.registry.resources.lock().await;
|
||||
// Subagents share the parent's terminal backend, so `list_tasks()`
|
||||
// returns tasks owned by other sessions. Scope the between-turn
|
||||
// "While you were idle, … background task completed" drain to tasks
|
||||
// this session owns, mirroring the per-tool-call
|
||||
// `TaskCompletionReminder` filter — otherwise a parent (or sibling)
|
||||
// bash task that finished mid-subagent-turn leaks its completion
|
||||
// also returns tasks owned by other sessions; without the owner scope
|
||||
// a parent or sibling bash task finishing mid-subagent-turn leaks its
|
||||
// "While you were idle, … background task completed"
|
||||
// `<system-reminder>` into the subagent's conversation. The owner
|
||||
// filter runs before `mark_reported` so the owning session still
|
||||
// reports the task on its own next turn.
|
||||
@@ -627,11 +568,9 @@ impl ToolBridge {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Construct a minimal bridge for tests. Has no tools registered.
|
||||
///
|
||||
/// Bypasses `ToolRegistryBuilder::finalize()` entirely so this can
|
||||
/// be called from sync `#[test]` functions that lack a tokio runtime.
|
||||
/// (`finalize()` spawns background tasks via `tokio::spawn`.)
|
||||
/// Minimal bridge with no tools registered. Bypasses
|
||||
/// `ToolRegistryBuilder::finalize()`, which `tokio::spawn`s background
|
||||
/// tasks, so sync `#[test]` functions without a runtime can call it.
|
||||
pub fn for_test() -> Self {
|
||||
let toolset = FinalizedToolset::empty_for_test();
|
||||
Self {
|
||||
@@ -703,9 +642,8 @@ mod tests {
|
||||
let bridge = ToolBridge::for_test();
|
||||
let toolset = bridge.toolset();
|
||||
|
||||
// PascalCase + kigi's snake_case in one registry
|
||||
// to exercise the lookup on the literal name strings each
|
||||
// namespace ships.
|
||||
// PascalCase and snake_case in one registry, to exercise the lookup on
|
||||
// the literal name strings each namespace ships.
|
||||
register_fixture(&toolset, "Write", ToolKind::Write, "fixture_write");
|
||||
register_fixture(
|
||||
&toolset,
|
||||
@@ -730,13 +668,9 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(bridge.tool_kind("not_a_registered_tool"), None);
|
||||
// Exact client-name lookup is case-sensitive.
|
||||
assert_eq!(bridge.tool_kind("write"), None);
|
||||
}
|
||||
|
||||
// ── drain_between_turn_bash_completions owner scoping (the "While you
|
||||
// were idle, … background task completed" path) ──
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MockTerminal {
|
||||
tasks: Vec<TaskSnapshot>,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Cgroup v2 memory-high monitor for graceful OOM handling.
|
||||
//!
|
||||
//! Approach:
|
||||
//!
|
||||
//! 1. On startup we create a child cgroup under the current process's cgroup and
|
||||
//! configure:
|
||||
//! - `memory.high` = soft limit (the "desired" ceiling)
|
||||
@@ -34,10 +32,6 @@
|
||||
/// Matches the POSIX convention: 128 + signal-number (SIGKILL = 9).
|
||||
pub const PROCESS_OOM_EXIT_CODE: i32 = 137;
|
||||
|
||||
// ============================================================================
|
||||
// Public types (cross-platform)
|
||||
// ============================================================================
|
||||
|
||||
/// Event emitted when `memory.high` is breached and RSS is still above
|
||||
/// the 90 % buffer threshold.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -48,7 +42,6 @@ pub struct MemoryHighEvent {
|
||||
pub memory_high_threshold: u64,
|
||||
}
|
||||
|
||||
/// Configuration for cgroup memory limits.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CgroupMemoryConfig {
|
||||
/// Soft memory limit (`memory.high`). When a process inside the cgroup
|
||||
@@ -61,17 +54,12 @@ pub struct CgroupMemoryConfig {
|
||||
}
|
||||
|
||||
impl CgroupMemoryConfig {
|
||||
/// memory.max = memory.high + headroom
|
||||
#[cfg(target_os = "linux")]
|
||||
fn memory_max(&self) -> u64 {
|
||||
self.memory_high_bytes.saturating_add(self.headroom_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
@@ -82,8 +70,6 @@ mod linux {
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::sync::watch;
|
||||
|
||||
// ── inotify FFI ──────────────────────────────────────────────────────
|
||||
|
||||
unsafe fn inotify_init1(flags: libc::c_int) -> std::io::Result<i32> {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let ret = unsafe { libc::syscall(libc::SYS_inotify_init1, flags) as libc::c_int };
|
||||
@@ -110,8 +96,6 @@ mod linux {
|
||||
|
||||
const IN_MODIFY: u32 = 0x0000_0002;
|
||||
|
||||
// ── Inotify wrapper ──────────────────────────────────────────────────
|
||||
|
||||
struct Inotify {
|
||||
fd: AsyncFd<OwnedFd>,
|
||||
}
|
||||
@@ -119,6 +103,8 @@ mod linux {
|
||||
impl Inotify {
|
||||
fn new() -> std::io::Result<Self> {
|
||||
let raw = unsafe { inotify_init1(libc::O_NONBLOCK | libc::O_CLOEXEC) }?;
|
||||
// SAFETY: `raw` comes from a successful `inotify_init1`, so it is a
|
||||
// live fd that nothing else owns.
|
||||
let owned = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
let fd = AsyncFd::with_interest(owned, Interest::READABLE)?;
|
||||
Ok(Inotify { fd })
|
||||
@@ -126,7 +112,9 @@ mod linux {
|
||||
|
||||
fn add_watch(&self, path: &std::path::Path) -> std::io::Result<i32> {
|
||||
let mut bytes = path.as_os_str().as_bytes().to_vec();
|
||||
bytes.push(0); // NUL-terminate
|
||||
bytes.push(0);
|
||||
// SAFETY: `bytes` is NUL-terminated above and outlives the call,
|
||||
// so the pointer is a valid C string for the kernel to read.
|
||||
let wd = unsafe {
|
||||
inotify_add_watch(
|
||||
self.fd.get_ref().as_raw_fd(),
|
||||
@@ -139,9 +127,12 @@ mod linux {
|
||||
|
||||
async fn wait_and_drain(&self) -> std::io::Result<()> {
|
||||
let mut guard = self.fd.readable().await?;
|
||||
// Drain all pending inotify events
|
||||
// Drain all pending inotify events; the fd is non-blocking, so the
|
||||
// read returns <= 0 once the queue is empty.
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
// SAFETY: writing at most `buf.len()` bytes into `buf`, through
|
||||
// an fd kept alive by `guard`.
|
||||
let n = unsafe {
|
||||
libc::read(
|
||||
guard.get_inner().as_raw_fd(),
|
||||
@@ -159,8 +150,6 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cgroup helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Read `/proc/self/cgroup` to find our own cgroup path (cgroup v2 unified).
|
||||
fn read_self_cgroup() -> std::io::Result<String> {
|
||||
let contents = std::fs::read_to_string("/proc/self/cgroup")?;
|
||||
@@ -176,7 +165,6 @@ mod linux {
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse the `high <N>` counter from `memory.events` contents.
|
||||
fn parse_memory_events_high(contents: &str) -> Option<u64> {
|
||||
for line in contents.lines() {
|
||||
if let Some(value) = line.strip_prefix("high ") {
|
||||
@@ -186,16 +174,12 @@ mod linux {
|
||||
None
|
||||
}
|
||||
|
||||
// ── CgroupHandle ─────────────────────────────────────────────────────
|
||||
|
||||
/// Owns the lifecycle of a child cgroup directory.
|
||||
pub(crate) struct CgroupHandle {
|
||||
fs_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CgroupHandle {
|
||||
/// Create a child cgroup under the current process's cgroup and
|
||||
/// configure memory limits.
|
||||
pub(crate) async fn create(config: &CgroupMemoryConfig) -> std::io::Result<Self> {
|
||||
let self_cgroup = read_self_cgroup()?;
|
||||
let name = format!("kigi-tools-{}", uuid::Uuid::now_v7());
|
||||
@@ -203,18 +187,16 @@ mod linux {
|
||||
|
||||
tokio::fs::create_dir_all(&fs_path).await?;
|
||||
|
||||
// Enable memory + cpu controllers in the child cgroup's parent
|
||||
// (the parent's subtree_control must list the controllers).
|
||||
// A controller only works in a child cgroup if the parent's
|
||||
// subtree_control lists it. Best-effort: it may already be enabled,
|
||||
// or we may lack permission to touch the parent.
|
||||
let parent = fs_path.parent().unwrap();
|
||||
let subtree_ctl = parent.join("cgroup.subtree_control");
|
||||
// Best-effort; may already be enabled or not permitted.
|
||||
let _ = tokio::fs::write(&subtree_ctl, "+memory +cpu").await;
|
||||
|
||||
// Configure memory.high (soft limit)
|
||||
let memory_high_path = fs_path.join("memory.high");
|
||||
tokio::fs::write(&memory_high_path, config.memory_high_bytes.to_string()).await?;
|
||||
|
||||
// Configure memory.max (hard limit = high + headroom)
|
||||
let memory_max_path = fs_path.join("memory.max");
|
||||
tokio::fs::write(&memory_max_path, config.memory_max().to_string()).await?;
|
||||
|
||||
@@ -228,13 +210,11 @@ mod linux {
|
||||
Ok(CgroupHandle { fs_path })
|
||||
}
|
||||
|
||||
/// Move a process (by PID) into this cgroup.
|
||||
pub(crate) async fn add_process(&self, pid: u32) -> std::io::Result<()> {
|
||||
let procs_path = self.fs_path.join("cgroup.procs");
|
||||
tokio::fs::write(&procs_path, pid.to_string()).await
|
||||
}
|
||||
|
||||
/// Read `memory.current` from this cgroup.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn memory_current(&self) -> std::io::Result<u64> {
|
||||
let s: String = tokio::fs::read_to_string(self.fs_path.join("memory.current")).await?;
|
||||
@@ -243,7 +223,6 @@ mod linux {
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Filesystem path to this cgroup.
|
||||
pub(crate) fn path(&self) -> &std::path::Path {
|
||||
&self.fs_path
|
||||
}
|
||||
@@ -252,9 +231,8 @@ mod linux {
|
||||
impl Drop for CgroupHandle {
|
||||
fn drop(&mut self) {
|
||||
let path = self.fs_path.clone();
|
||||
// Always use tokio::spawn: the cleanup future is Send and Drop
|
||||
// can fire after the LocalSet has shut down, making spawn_local
|
||||
// unsafe here.
|
||||
// `tokio::spawn` rather than `spawn_local`: the cleanup future is
|
||||
// Send, and Drop can fire after the LocalSet has shut down.
|
||||
tokio::spawn(async move {
|
||||
let kill_path = path.join("cgroup.kill");
|
||||
let _ = tokio::fs::write(&kill_path, "1").await;
|
||||
@@ -269,8 +247,6 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// ── MemoryHighMonitor ────────────────────────────────────────────────
|
||||
|
||||
/// Watches `memory.events` via inotify and signals when the `high`
|
||||
/// counter increments while RSS is still above 90% of the threshold.
|
||||
pub(crate) struct MemoryHighMonitor {
|
||||
@@ -280,7 +256,6 @@ mod linux {
|
||||
}
|
||||
|
||||
impl MemoryHighMonitor {
|
||||
/// Start monitoring the given cgroup for memory.high events.
|
||||
pub(crate) async fn start(
|
||||
cgroup_path: PathBuf,
|
||||
memory_high_threshold: u64,
|
||||
@@ -324,23 +299,21 @@ mod linux {
|
||||
let events_path = cgroup_path.join("memory.events");
|
||||
let current_path = cgroup_path.join("memory.current");
|
||||
|
||||
// Read baseline high counter
|
||||
// The `high` counter is cumulative and may already be non-zero, so
|
||||
// only increments past this baseline count as a breach.
|
||||
let mut last_high_count = Self::read_high_counter(&events_path).await.unwrap_or(0);
|
||||
|
||||
loop {
|
||||
// Block until inotify fires (memory.events was modified)
|
||||
if inotify.wait_and_drain().await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read new high counter
|
||||
let current_high = Self::read_high_counter(&events_path).await.unwrap_or(0);
|
||||
if current_high <= last_high_count {
|
||||
continue;
|
||||
}
|
||||
last_high_count = current_high;
|
||||
|
||||
// Read current memory usage
|
||||
let memory_current = match tokio::fs::read_to_string(¤t_path).await {
|
||||
Ok(s) => {
|
||||
let s: String = s;
|
||||
@@ -357,8 +330,10 @@ mod linux {
|
||||
memory_current,
|
||||
memory_high_threshold,
|
||||
};
|
||||
// A send error means every receiver is gone, so there is
|
||||
// nobody left to warn.
|
||||
if tx.send(Some(event)).is_err() {
|
||||
break; // receiver dropped
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,26 +352,17 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cross-platform re-exports
|
||||
// ============================================================================
|
||||
|
||||
/// Cgroup handle — owns the child cgroup's lifecycle.
|
||||
///
|
||||
/// On Linux, this creates a real cgroupv2 directory with memory limits.
|
||||
/// On other platforms, this is a no-op.
|
||||
/// Owns the child cgroup's lifecycle: a real cgroupv2 directory with memory
|
||||
/// limits on Linux, a no-op elsewhere.
|
||||
pub struct CgroupGuard {
|
||||
#[cfg(target_os = "linux")]
|
||||
inner: Option<linux::CgroupHandle>,
|
||||
}
|
||||
|
||||
impl CgroupGuard {
|
||||
/// Try to create a cgroup with the given memory config.
|
||||
/// Returns a guard that cleans up the cgroup on drop.
|
||||
///
|
||||
/// On non-Linux platforms this always returns a no-op guard.
|
||||
/// On Linux, if cgroup creation fails (e.g., not running as root,
|
||||
/// cgroupv2 not available), it logs a warning and returns a no-op guard.
|
||||
/// The returned guard cleans up the cgroup on drop. Never fails: when
|
||||
/// creation is impossible (non-Linux, no root, no cgroupv2) the caller
|
||||
/// gets a no-op guard and the process simply runs unlimited.
|
||||
pub async fn try_create(config: &CgroupMemoryConfig) -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -417,7 +383,6 @@ impl CgroupGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op guard with no backing cgroup.
|
||||
pub fn noop() -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -429,8 +394,7 @@ impl CgroupGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a process into this cgroup by PID.
|
||||
/// No-op if cgroup was not created.
|
||||
/// No-op, and still `Ok`, when there is no backing cgroup.
|
||||
pub async fn add_process(&self, _pid: u32) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -441,7 +405,6 @@ impl CgroupGuard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the cgroup filesystem path, if available.
|
||||
#[allow(dead_code)]
|
||||
pub fn path(&self) -> Option<&std::path::Path> {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -453,7 +416,6 @@ impl CgroupGuard {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns true if this guard has a real cgroup backing it.
|
||||
pub fn is_active(&self) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -466,18 +428,16 @@ impl CgroupGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory-high monitor — watches for memory pressure events.
|
||||
///
|
||||
/// On Linux, uses inotify on `memory.events`.
|
||||
/// On other platforms, this is a no-op that never fires.
|
||||
/// Watches for memory pressure through inotify on `memory.events`; on
|
||||
/// non-Linux platforms it never fires.
|
||||
pub struct MemoryMonitor {
|
||||
#[cfg(target_os = "linux")]
|
||||
inner: Option<linux::MemoryHighMonitor>,
|
||||
}
|
||||
|
||||
impl MemoryMonitor {
|
||||
/// Start monitoring the given cgroup guard for memory.high events.
|
||||
/// Returns a no-op monitor if the guard has no backing cgroup.
|
||||
/// Yields a no-op monitor when the guard has no backing cgroup or the
|
||||
/// inotify watch cannot be established.
|
||||
pub async fn start(
|
||||
guard: &CgroupGuard,
|
||||
config: &CgroupMemoryConfig,
|
||||
@@ -512,7 +472,6 @@ impl MemoryMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op monitor that never fires.
|
||||
pub fn noop() -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
//! Shadow `find`→`bfs` and `grep`→`ugrep` when those binaries resolve.
|
||||
//!
|
||||
//! Per-tool enable state (default on) is resolved by the host via the shared
|
||||
//! config helper `kigi-shell::util::config::resolve_search_tools_enabled`
|
||||
//! (requirements > env `KIGI_TOOLS_FIND_BFS` / `KIGI_TOOLS_GREP_UGREP` (+
|
||||
//! `KIGI_FIND_BFS` / `KIGI_GREP_UGREP` aliases, `DISABLE_EMBEDDED_SEARCH_TOOLS`
|
||||
//! master) > `[toolset.bash]` config.toml > managed > default), baked into the
|
||||
//! Per-tool enable state (default on) is resolved by the host via
|
||||
//! `kigi-shell::util::config::resolve_search_tools_enabled` (requirements > env
|
||||
//! `KIGI_TOOLS_FIND_BFS` / `KIGI_TOOLS_GREP_UGREP` (+ `KIGI_FIND_BFS` /
|
||||
//! `KIGI_GREP_UGREP` aliases, `DISABLE_EMBEDDED_SEARCH_TOOLS` master) >
|
||||
//! `[toolset.bash]` config.toml > managed > default), baked into the
|
||||
//! `LocalTerminalBackend` as a [`SearchShadowConfig`] and passed to
|
||||
//! [`search_injection`] per command. The enable state lives on the backend (not
|
||||
//! a process-global): a subagent that reuses the parent's backend inherits the
|
||||
//! parent's shadows instead of clobbering a shared static. This module no longer
|
||||
//! parses the flags itself.
|
||||
//! [`search_injection`] per command.
|
||||
//!
|
||||
//! Resolve (host side, memoized): env override if a regular file → bundled binary
|
||||
//! (release builds, self-extracted to `~/.kigi/vendor/<name>-<ver>-<target>`) →
|
||||
//! `~/.kigi/vendor/{name}` if a regular file → `which` on the agent `$PATH`.
|
||||
//! Env/vendor only require `is_file()` as a lenient hint (no `--version` probe).
|
||||
//! This memoized path is only a *hint*: the injected shadow re-resolves at
|
||||
//! **call time** — it uses the hint when it's still *executable* (`[ -x ]`), else
|
||||
//! `command -v {bin}` on the live shell `PATH` (which includes login/rc additions
|
||||
//! the agent process may lack), else falls back to the OS `{name}`. So a removed
|
||||
//! or non-executable binary self-heals to OS `find`/`grep`, and a binary
|
||||
//! reachable only through the login shell is still found.
|
||||
//! Host-side resolution (memoized) is only a *hint*: env override if a regular
|
||||
//! file → bundled binary (release builds, self-extracted to
|
||||
//! `~/.kigi/vendor/<name>-<ver>-<target>`) → `~/.kigi/vendor/{name}` if a
|
||||
//! regular file → `which` on the agent `$PATH`. The injected shadow re-resolves
|
||||
//! at **call time**, trusting the hint only while it is *executable* (`[ -x ]`),
|
||||
//! else `command -v {bin}` on the live shell `PATH` (which includes login/rc
|
||||
//! additions the agent process may lack), else the OS `{name}`. So a deleted or
|
||||
//! non-executable binary self-heals to OS `find`/`grep`, and a binary reachable
|
||||
//! only through the login shell is still found.
|
||||
//!
|
||||
//! Inject is **always** non-empty on Unix callers: either install a shadow
|
||||
//! function (which tags itself with a `__kigi_shadow_{name}` marker) or a
|
||||
//! marker-gated `unalias`+`unset -f` that drops *only* a prior harness shadow —
|
||||
//! never a user-defined `find`/`grep` function replayed from the snapshot.
|
||||
//! Inject is **always** non-empty on Unix callers: either a shadow function
|
||||
//! (which tags itself with a `__kigi_shadow_{name}` marker) or a marker-gated
|
||||
//! `unalias`+`unset -f` that drops *only* a prior harness shadow — never a
|
||||
//! user-defined `find`/`grep` function replayed from the snapshot.
|
||||
|
||||
use super::SearchShadowConfig;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -69,22 +65,13 @@ const UGREP_BYTES: &[u8] = include_bytes!(concat!(
|
||||
));
|
||||
|
||||
/// Oneline inject for shell wrappers; always ends with `"; "`.
|
||||
///
|
||||
/// `cfg` is the backend's resolved per-tool enable state (see module docs); it
|
||||
/// is passed in per command rather than read from a process-global so subagents
|
||||
/// sharing a backend can't clobber each other's shadows.
|
||||
pub fn search_injection(cfg: SearchShadowConfig) -> String {
|
||||
build_injection(cfg.find_bfs, cfg.grep_ugrep, resolved_tools())
|
||||
}
|
||||
|
||||
/// Compose the inject from per-tool enable flags + resolved binaries. An enabled
|
||||
/// tool installs a self-resolving shadow (the memoized `tools` path is only a
|
||||
/// fast-path hint; the shadow re-resolves at call time and falls back to the OS
|
||||
/// binary — see [`shell_function`]). A disabled tool emits a marker-gated
|
||||
/// `restore` that drops only a prior harness shadow. Kept pure (flags/tools
|
||||
/// passed in) so tests need no process-global env mutation — that is UB against
|
||||
/// the `shell_state` integration tests that read env / spawn children
|
||||
/// concurrently.
|
||||
/// Flags and tools are parameters rather than globals so tests need no
|
||||
/// process-global env mutation — that is UB against the `shell_state`
|
||||
/// integration tests, which read env and spawn children concurrently.
|
||||
fn build_injection(find_on: bool, grep_on: bool, tools: &ResolvedTools) -> String {
|
||||
let find = if find_on {
|
||||
shell_function("find", "bfs", tools.bfs.as_deref(), &[])
|
||||
@@ -99,11 +86,11 @@ fn build_injection(find_on: bool, grep_on: bool, tools: &ResolvedTools) -> Strin
|
||||
format!("{find}; {grep}; ")
|
||||
}
|
||||
|
||||
/// Drop a *previously installed harness* shadow so command-word `{name}` uses the
|
||||
/// OS binary again. Gated on the `__kigi_shadow_{name}` marker that
|
||||
/// [`shell_function`] sets, so a user-defined `{name}` function replayed from the
|
||||
/// shell snapshot is left intact — only the harness's own shadow is removed.
|
||||
/// `set -u`/`set -e` safe and idempotent (`unset -f` is bash + zsh).
|
||||
/// Drop a harness shadow so command-word `{name}` reaches the OS binary again.
|
||||
/// Gated on the `__kigi_shadow_{name}` marker that [`shell_function`] sets, so a
|
||||
/// user-defined `{name}` function replayed from the shell snapshot survives —
|
||||
/// only the harness's own shadow goes. `set -u`/`set -e` safe and idempotent
|
||||
/// (`unset -f` is bash + zsh).
|
||||
fn restore_command(name: &str) -> String {
|
||||
format!(
|
||||
"if [ -n \"${{__kigi_shadow_{name}-}}\" ]; then \
|
||||
@@ -128,7 +115,7 @@ fn resolved_tools() -> &'static ResolvedTools {
|
||||
}
|
||||
|
||||
/// Write embedded `bytes` to `~/.kigi/vendor/<versioned_name>` (chmod 755) on
|
||||
/// first use and return the path; reused on later runs. Versioned so bumping the
|
||||
/// first use; later runs reuse it. The version in the name means bumping the
|
||||
/// bundled version writes a fresh file instead of reusing a stale one.
|
||||
#[cfg(any(bundle_bfs, bundle_ugrep))]
|
||||
fn extract_bundled(versioned_name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
@@ -164,7 +151,6 @@ fn extract_bundled(versioned_name: &str, bytes: &[u8]) -> std::io::Result<PathBu
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Path to the bundled `bfs` (extracted on first use), or `None` when not bundled.
|
||||
fn bundled_bfs() -> Option<PathBuf> {
|
||||
#[cfg(bundle_bfs)]
|
||||
{
|
||||
@@ -185,7 +171,6 @@ fn bundled_bfs() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the bundled `ugrep` (extracted on first use), or `None` when not bundled.
|
||||
fn bundled_ugrep() -> Option<PathBuf> {
|
||||
#[cfg(bundle_ugrep)]
|
||||
{
|
||||
@@ -215,11 +200,10 @@ fn resolve_tool(bin_name: &str, env_override: &str, bundled: Option<PathBuf>) ->
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolution order: explicit env path → bundled (self-extracted) →
|
||||
/// `~/.kigi/vendor/<bin>` → `which`. Env and vendor only require `is_file()` here
|
||||
/// (a lenient hint, no `+x` probe) so an odd-permission copy still resolves; the
|
||||
/// injected shadow gates on `[ -x ]` at call time and falls back to the OS binary
|
||||
/// if the hint isn't executable, so a non-exec path can't hard-fail `find`/`grep`.
|
||||
/// Env and vendor candidates only need `is_file()` — no `+x` probe — so an
|
||||
/// odd-permission copy still resolves as a hint; the injected shadow gates on
|
||||
/// `[ -x ]` at call time and reaches the OS binary when the hint isn't
|
||||
/// executable, so a non-exec path can't hard-fail `find`/`grep`.
|
||||
fn resolve_tool_from(
|
||||
env_path: Option<PathBuf>,
|
||||
bundled: Option<PathBuf>,
|
||||
@@ -251,22 +235,14 @@ fn bash_safe_quote(s: &str) -> String {
|
||||
|
||||
/// Oneline `name() { … }` for `-c` inject — a *self-resolving* shadow.
|
||||
///
|
||||
/// At call time it picks the binary: the host-resolved `preferred` path
|
||||
/// (bundled/env/vendor/which) when that file still exists, else `command -v
|
||||
/// {bin_name}` on the live shell `PATH` (which carries login/rc additions the
|
||||
/// agent process may not have), else it falls back to the OS `{name}`. This keeps
|
||||
/// the fast hard-coded path for the common case while self-healing when the
|
||||
/// binary was removed (revalidation) or is only reachable through the shell's
|
||||
/// richer `PATH`.
|
||||
///
|
||||
/// `exec -a` runs inside a subshell so a top-level call can't replace the wrapper
|
||||
/// shell (it must survive to dump state); a call already inside a subshell
|
||||
/// (`BASH_SUBSHELL > 0`, bash) execs directly to skip a fork. `${ZSH_VERSION-}`
|
||||
/// keeps the probe `set -u`-safe (a bare `$ZSH_VERSION` aborts bash under
|
||||
/// nounset). `exec -a` gives the binary the `find`/`grep` argv0 (ps display +
|
||||
/// ugrep grep-personality) in both bash and zsh. The trailing
|
||||
/// `exec -a` runs inside a subshell so a top-level call can't replace the
|
||||
/// wrapper shell, which must survive to dump state; a call already inside a
|
||||
/// subshell (`BASH_SUBSHELL > 0`, bash) execs directly to skip a fork. `exec -a`
|
||||
/// also gives the binary the `find`/`grep` argv0 (ps display + ugrep
|
||||
/// grep-personality) in both bash and zsh. `${ZSH_VERSION-}` keeps the probe
|
||||
/// `set -u`-safe — a bare `$ZSH_VERSION` aborts bash under nounset. The trailing
|
||||
/// `__kigi_shadow_{name}=1` marks this as a harness shadow so `restore_command`
|
||||
/// only ever removes our own function — never a user's.
|
||||
/// only ever drops our own function, never a user's.
|
||||
fn shell_function(
|
||||
name: &str,
|
||||
bin_name: &str,
|
||||
@@ -284,16 +260,15 @@ fn shell_function(
|
||||
format!("{} ", qargs.join(" "))
|
||||
}
|
||||
};
|
||||
// `local __kigi_bin` is re-resolved every call. The host hint is trusted
|
||||
// only when it's *executable* (`[ -x ]`, not just `[ -f ]`): the resolver
|
||||
// accepts any regular file as a hint, but `exec` needs `+x`, so a non-exec
|
||||
// hint must fall through rather than hard-fail with no OS fallback. Then
|
||||
// `command -v` on the live shell PATH (returns an executable), else the OS
|
||||
// binary. `|| __kigi_bin=''` keeps the lookup `set -e`-safe (a failed
|
||||
// `command -v` would otherwise abort the function under errexit). The OS
|
||||
// fallback uses `command {name}` to bypass this function. `{prepend}` is
|
||||
// empty for find, the ugrep default flags for grep (and is omitted from the
|
||||
// OS fallback, which gets the original args).
|
||||
// `__kigi_bin` re-resolves on every call. The host hint counts only while
|
||||
// *executable* (`[ -x ]`, not just `[ -f ]`): the resolver accepts any
|
||||
// regular file as a hint, but `exec` needs `+x`, so a non-exec hint must
|
||||
// fall through rather than hard-fail with no OS fallback. Then `command -v`
|
||||
// on the live shell PATH, else the OS binary via `command {name}`, which
|
||||
// bypasses this function. `|| __kigi_bin=''` keeps the lookup `set -e`-safe:
|
||||
// a failed `command -v` would otherwise abort the function under errexit.
|
||||
// `{prepend}` is empty for find and the ugrep default flags for grep; the OS
|
||||
// fallback omits it and gets the original args.
|
||||
format!(
|
||||
"unalias {name} 2>/dev/null || true; \
|
||||
{name}() {{ \
|
||||
@@ -314,7 +289,6 @@ fn shell_function(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Both binaries resolved, for `build_injection` shape tests.
|
||||
fn both_tools() -> ResolvedTools {
|
||||
ResolvedTools {
|
||||
bfs: Some(PathBuf::from("/tmp/bfs")),
|
||||
@@ -326,28 +300,23 @@ mod tests {
|
||||
fn shell_function_shape() {
|
||||
let fn_body = shell_function("find", "bfs", Some(Path::new("/tmp/bfs")), &[]);
|
||||
assert!(fn_body.contains("unalias find"));
|
||||
// Preferred path is the fast-path hint; the shadow execs `$__kigi_bin`.
|
||||
assert!(fn_body.contains("local __kigi_bin=/tmp/bfs"));
|
||||
assert!(fn_body.contains("exec -a find \"$__kigi_bin\" \"$@\""));
|
||||
// Hint is trusted only when executable (`[ -x ]`, not `[ -f ]`), so a
|
||||
// non-exec hint falls through instead of hard-failing exec.
|
||||
// `[ -f ]` would hard-fail exec on a non-executable hint.
|
||||
assert!(fn_body.contains("[ -x \"$__kigi_bin\" ]"));
|
||||
assert!(!fn_body.contains("[ -f \"$__kigi_bin\" ]"));
|
||||
// Self-heal: live-PATH lookup + OS fallback.
|
||||
assert!(fn_body.contains("command -v bfs"));
|
||||
assert!(fn_body.contains("command find \"$@\""));
|
||||
assert!(fn_body.contains("BASH_SUBSHELL > 0"));
|
||||
assert!(fn_body.contains("(exec -a find"));
|
||||
// Marker so `restore_command` only removes our own shadow.
|
||||
assert!(fn_body.contains("__kigi_shadow_find=1"));
|
||||
// set -u-safe zsh probe (a bare $ZSH_VERSION aborts bash under nounset).
|
||||
// A bare `$ZSH_VERSION` aborts bash under nounset.
|
||||
assert!(fn_body.contains("${ZSH_VERSION-}"));
|
||||
assert!(!fn_body.contains("[[ -n $ZSH_VERSION ]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_function_unresolved_uses_empty_hint() {
|
||||
// No host-resolved path → empty hint, relies on live-PATH `command -v`.
|
||||
let fn_body = shell_function("find", "bfs", None, &[]);
|
||||
assert!(fn_body.contains("local __kigi_bin=''"));
|
||||
assert!(fn_body.contains("command -v bfs"));
|
||||
@@ -380,7 +349,6 @@ mod tests {
|
||||
#[test]
|
||||
fn restore_command_is_marker_gated() {
|
||||
let r = restore_command("find");
|
||||
// Only removes the harness shadow when our marker is set.
|
||||
assert!(r.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
|
||||
assert!(r.contains("unalias find"));
|
||||
assert!(r.contains("unset -f find"));
|
||||
@@ -389,7 +357,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn config_default_is_both_on() {
|
||||
// Standalone/no-host backends default to shadowing both tools.
|
||||
let cfg = SearchShadowConfig::default();
|
||||
assert!(cfg.find_bfs);
|
||||
assert!(cfg.grep_ugrep);
|
||||
@@ -397,8 +364,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_injection_off_emits_marker_gated_restore_not_function() {
|
||||
// Disabled tools emit a marker-gated restore so a stale harness shadow
|
||||
// from a prior snapshot is dropped, but a user function is left intact.
|
||||
let inject = build_injection(false, false, &both_tools());
|
||||
assert!(inject.ends_with("; "));
|
||||
assert!(inject.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
|
||||
@@ -420,8 +385,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_injection_enabled_unresolved_still_self_heals() {
|
||||
// Enabled but no host-resolved path → still install a self-resolving
|
||||
// shadow (live-PATH `command -v` + OS fallback), never a bare restore.
|
||||
// No host-resolved path must still yield a shadow, not a bare restore.
|
||||
let tools = ResolvedTools {
|
||||
bfs: None,
|
||||
ugrep: None,
|
||||
@@ -431,16 +395,14 @@ mod tests {
|
||||
assert!(inject.contains("grep()"));
|
||||
assert!(inject.contains("command -v bfs"));
|
||||
assert!(inject.contains("command -v ugrep"));
|
||||
// OS fallback present; not a marker-gated restore.
|
||||
assert!(inject.contains("command find \"$@\""));
|
||||
assert!(!inject.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injection_always_nonempty_and_trailing_sep() {
|
||||
// Structural invariants that hold regardless of host flags/binaries:
|
||||
// never empty (so a stale snapshot shadow is always overwritten) and a
|
||||
// trailing `"; "` separator before the user command.
|
||||
// Never empty regardless of flags, so a stale snapshot shadow is always
|
||||
// overwritten; trailing `"; "` separates it from the user command.
|
||||
for cfg in [
|
||||
SearchShadowConfig {
|
||||
find_bfs: true,
|
||||
@@ -477,7 +439,6 @@ mod tests {
|
||||
perms.set_mode(0o644);
|
||||
std::fs::set_permissions(&bin, perms).unwrap();
|
||||
}
|
||||
// Bundled + vendor intentionally absent so the env override is what wins.
|
||||
let got = resolve_tool_from(
|
||||
Some(bin.clone()),
|
||||
None,
|
||||
@@ -506,7 +467,6 @@ mod tests {
|
||||
std::fs::write(p, b"x").unwrap();
|
||||
}
|
||||
|
||||
// env override beats everything.
|
||||
assert_eq!(
|
||||
resolve_tool_from(
|
||||
Some(envp.clone()),
|
||||
@@ -517,12 +477,10 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some(envp.as_path())
|
||||
);
|
||||
// bundled beats the manual vendor copy.
|
||||
assert_eq!(
|
||||
resolve_tool_from(None, Some(bundled.clone()), vendor.clone(), "bfs").as_deref(),
|
||||
Some(bundled.as_path())
|
||||
);
|
||||
// vendor used when neither env nor bundled is present.
|
||||
assert_eq!(
|
||||
resolve_tool_from(None, None, vendor.clone(), "bfs").as_deref(),
|
||||
Some(vendor.as_path())
|
||||
@@ -536,9 +494,8 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Only compiled when the binaries are actually bundled (release pipeline, or
|
||||
/// `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` at build time). Verifies the embedded
|
||||
/// bytes self-extract under `~/.kigi/vendor` and the extracted `bfs` runs.
|
||||
/// Only compiles in the release pipeline, or when
|
||||
/// `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` is set at build time.
|
||||
#[cfg(all(bundle_bfs, bundle_ugrep))]
|
||||
#[test]
|
||||
fn bundled_binaries_extract_and_run() {
|
||||
|
||||
@@ -4,7 +4,6 @@ use tokio::{fs, time::sleep};
|
||||
|
||||
use crate::computer::types::{AsyncFileSystem, ComputerError};
|
||||
|
||||
/// Creates a local FS access which allows writing and reading from the local files
|
||||
pub struct LocalFs;
|
||||
|
||||
// Keep the window short: these retries absorb brief Windows editor/indexer/AV
|
||||
@@ -21,8 +20,8 @@ const WINDOWS_ERROR_SHARING_VIOLATION: i32 = 32;
|
||||
#[cfg(any(windows, test))]
|
||||
const WINDOWS_ERROR_LOCK_VIOLATION: i32 = 33;
|
||||
|
||||
/// Check if an IO error is a permission denial (EACCES or EPERM),
|
||||
/// which indicates a sandbox violation.
|
||||
/// EACCES/EPERM here means the sandbox denied the operation, so callers report
|
||||
/// it to `kigi_sandbox` rather than treating it as an ordinary IO failure.
|
||||
fn is_permission_error(e: &io::Error) -> bool {
|
||||
matches!(e.kind(), io::ErrorKind::PermissionDenied)
|
||||
}
|
||||
@@ -48,6 +47,9 @@ fn is_transient_write_lock_error(e: &io::Error) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies the Windows codes on every platform so the retry-hook tests below
|
||||
/// can exercise the retry loop off Windows, where `is_transient_write_lock_error`
|
||||
/// is hardwired to `false`.
|
||||
#[cfg(test)]
|
||||
fn is_test_transient_write_lock_error(e: &io::Error) -> bool {
|
||||
is_windows_transient_write_lock_raw_os_error(e.raw_os_error())
|
||||
@@ -146,7 +148,6 @@ impl AsyncFileSystem for LocalFs {
|
||||
|
||||
#[tracing::instrument(name = "fs.write_file", skip_all)]
|
||||
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError> {
|
||||
// implicitly creates the missing directories if any
|
||||
if let Some(dir) = path.parent()
|
||||
&& let Err(e) = fs::create_dir_all(dir).await
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Mock file system implementation for testing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -8,7 +6,6 @@ use tokio::sync::RwLock;
|
||||
use crate::computer::types::{AsyncFileSystem, ComputerError};
|
||||
|
||||
/// In-memory file system for testing.
|
||||
/// Thread-safe and async-compatible.
|
||||
pub struct MockFs {
|
||||
files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
|
||||
}
|
||||
@@ -20,14 +17,12 @@ impl Default for MockFs {
|
||||
}
|
||||
|
||||
impl MockFs {
|
||||
/// Create a new empty mock file system.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
files: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a file's contents directly (for test setup).
|
||||
pub async fn set_file(&self, path: impl AsRef<Path>, content: &[u8]) {
|
||||
self.files
|
||||
.write()
|
||||
@@ -35,17 +30,14 @@ impl MockFs {
|
||||
.insert(path.as_ref().to_path_buf(), content.to_vec());
|
||||
}
|
||||
|
||||
/// Get a file's contents directly (for test assertions).
|
||||
pub async fn get_file(&self, path: impl AsRef<Path>) -> Option<Vec<u8>> {
|
||||
self.files.read().await.get(path.as_ref()).cloned()
|
||||
}
|
||||
|
||||
/// Check if a file exists.
|
||||
pub async fn exists(&self, path: impl AsRef<Path>) -> bool {
|
||||
self.files.read().await.contains_key(path.as_ref())
|
||||
}
|
||||
|
||||
/// List all files in the mock filesystem.
|
||||
pub async fn list_files(&self) -> Vec<PathBuf> {
|
||||
self.files.read().await.keys().cloned().collect()
|
||||
}
|
||||
@@ -84,15 +76,12 @@ mod tests {
|
||||
async fn test_mock_fs_read_write() {
|
||||
let fs = MockFs::new();
|
||||
|
||||
// File doesn't exist initially
|
||||
assert!(fs.read_file(Path::new("/test.txt")).await.is_err());
|
||||
|
||||
// Write a file
|
||||
fs.write_file(Path::new("/test.txt"), b"hello world")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Read it back
|
||||
let content = fs.read_file(Path::new("/test.txt")).await.unwrap();
|
||||
assert_eq!(content, b"hello world");
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ pub use terminal::{ExitStatus, LocalTerminalBackend};
|
||||
/// and baked into a [`LocalTerminalBackend`] at creation. Keeping it on the
|
||||
/// backend instead of a process-global means a subagent that reuses the parent's
|
||||
/// `LocalTerminalBackend` inherits the parent's shadows — it can't overwrite the
|
||||
/// enable state for bash that later runs on the shared backend. Defaults to
|
||||
/// both-on for standalone backends with no host wiring.
|
||||
/// enable state for bash that later runs on the shared backend. Standalone
|
||||
/// backends with no host wiring get the both-on default.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SearchShadowConfig {
|
||||
pub find_bfs: bool,
|
||||
|
||||
@@ -19,10 +19,6 @@ use command_fds::FdMapping;
|
||||
use nix::libc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
// ============================================================================
|
||||
// Marker constants
|
||||
// ============================================================================
|
||||
|
||||
const BASH_STATE_START_MARKER: &str = "__KIGI_BASH_STATE_START__";
|
||||
const BASH_STATE_END_MARKER: &str = "__KIGI_BASH_STATE_END__";
|
||||
const ZSH_STATE_START_MARKER: &str = "__KIGI_ZSH_STATE_START__";
|
||||
@@ -32,12 +28,11 @@ const ZSH_STATE_END_MARKER: &str = "__KIGI_ZSH_STATE_END__";
|
||||
/// from the actual state dump on stdout.
|
||||
const INIT_STATE_MARKER: &str = "__KIGI_INIT_STATE_MARKER__";
|
||||
|
||||
/// Maximum time to wait for a shell state init (login shell + rc files).
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Maximum time to wait for the dump reader task after the child process exits.
|
||||
/// Uses a 5s close timeout. If a background process inherits fd 4,
|
||||
/// the reader would hang forever without this.
|
||||
/// Cap on the dump reader task after the child process exits: a background
|
||||
/// process that inherited fd 4 keeps the pipe open, so the reader would
|
||||
/// otherwise wait forever.
|
||||
const DUMP_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Environment overrides applied to every agent terminal / persistent shell spawn.
|
||||
@@ -56,9 +51,8 @@ pub fn shell_env_overrides() -> HashMap<String, String> {
|
||||
])
|
||||
}
|
||||
|
||||
/// Returns the sudo alias injection string if `SUDO_ASKPASS` is configured.
|
||||
/// When set, `alias sudo='sudo -A'` makes any `sudo` in the user's command
|
||||
/// use the askpass helper instead of blocking on tty input.
|
||||
/// Aliasing `sudo` to `sudo -A` makes any `sudo` in the user's command use the
|
||||
/// askpass helper instead of blocking on tty input. Empty when no helper is set.
|
||||
fn sudo_alias_injection() -> String {
|
||||
match std::env::var("SUDO_ASKPASS") {
|
||||
Ok(val) if !val.is_empty() => "alias sudo='sudo -A'; ".to_string(),
|
||||
@@ -66,12 +60,8 @@ fn sudo_alias_injection() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dump scripts (embedded as const strings)
|
||||
// ============================================================================
|
||||
|
||||
/// Bash state dump script. Captures env vars, POSIX options, bash options,
|
||||
/// functions, and aliases as base64-encoded replayable shell snippets.
|
||||
/// Captures env vars, POSIX options, bash options, functions, and aliases as
|
||||
/// base64-encoded replayable shell snippets.
|
||||
const DUMP_BASH_STATE_SCRIPT: &str = r##"
|
||||
dump_bash_state() {
|
||||
set -euo pipefail
|
||||
@@ -125,8 +115,8 @@ dump_bash_state() {
|
||||
}
|
||||
"##;
|
||||
|
||||
/// Zsh state dump script. Captures env vars, zsh options, functions, and aliases
|
||||
/// as base64-encoded replayable shell snippets.
|
||||
/// Captures env vars, zsh options, functions, and aliases as base64-encoded
|
||||
/// replayable shell snippets.
|
||||
const DUMP_ZSH_STATE_SCRIPT: &str = r##"
|
||||
function dump_zsh_state() {
|
||||
emulate -L zsh -o errreturn -o pipefail
|
||||
@@ -175,10 +165,6 @@ function dump_zsh_state() {
|
||||
}
|
||||
"##;
|
||||
|
||||
// ============================================================================
|
||||
// Shell kind
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShellKind {
|
||||
Bash,
|
||||
@@ -195,9 +181,8 @@ impl ShellKind {
|
||||
}
|
||||
|
||||
/// Resolved absolute path to the shell binary. Falls back from `$SHELL` →
|
||||
/// `which` → common dirs → `/bin/<name>`. Result is cached process-wide
|
||||
/// in `kigi_config::shell::unix_shell_path`. See that function for
|
||||
/// the full cascade. Returns `&'static str`.
|
||||
/// `which` → common dirs → `/bin/<name>`, cached process-wide in
|
||||
/// `kigi_config::shell::unix_shell_path`; see that function for the full cascade.
|
||||
pub fn binary_path(&self) -> &'static str {
|
||||
let kind = match self {
|
||||
Self::Bash => kigi_config::shell::UnixShellKind::Bash,
|
||||
@@ -243,10 +228,6 @@ impl ShellKind {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ShellState
|
||||
// ============================================================================
|
||||
|
||||
/// Persistent shell state: a serialized snapshot that can be replayed to restore
|
||||
/// env vars, cwd, functions, aliases, and shell options in a fresh shell process.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -255,7 +236,6 @@ pub struct ShellState {
|
||||
pub cwd: PathBuf,
|
||||
/// Replayable shell script (everything after the cwd line, minus markers).
|
||||
pub snapshot: String,
|
||||
/// Which shell produced this state.
|
||||
pub shell: ShellKind,
|
||||
}
|
||||
|
||||
@@ -271,8 +251,6 @@ impl ShellState {
|
||||
let dump_script = shell.dump_script();
|
||||
let dump_fn = shell.dump_function_name();
|
||||
|
||||
// Build the one-liner: define the dump function, print a marker (to separate
|
||||
// login noise from our output), then call the dump function.
|
||||
let script = format!("{dump_script} builtin printf '{INIT_STATE_MARKER}\\n'; {dump_fn}");
|
||||
|
||||
let args: Vec<&str> = match shell {
|
||||
@@ -301,7 +279,7 @@ impl ShellState {
|
||||
|
||||
let mut full_output = String::new();
|
||||
if let Some(ref mut stdout) = child.stdout {
|
||||
// Apply init timeout to prevent hangs from slow rc files (e.g. network mounts).
|
||||
// Slow rc files (e.g. on network mounts) must not hang the init.
|
||||
match tokio::time::timeout(INIT_TIMEOUT, stdout.read_to_string(&mut full_output)).await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
@@ -320,7 +298,6 @@ impl ShellState {
|
||||
|
||||
let _ = child.wait().await;
|
||||
|
||||
// Extract output after our marker (skip MOTD, bashrc echo, etc.)
|
||||
let snapshot_raw = parse_after_marker(&full_output, INIT_STATE_MARKER);
|
||||
|
||||
match parse_dump(shell, snapshot_raw) {
|
||||
@@ -330,7 +307,6 @@ impl ShellState {
|
||||
shell,
|
||||
}),
|
||||
None => {
|
||||
// Dump failed or was killed — use empty state with the given cwd.
|
||||
tracing::warn!("shell state init: dump markers missing, using empty state");
|
||||
Ok(Self {
|
||||
cwd: cwd.to_path_buf(),
|
||||
@@ -367,21 +343,17 @@ impl ShellState {
|
||||
let sudo_inject = sudo_alias_injection();
|
||||
let search_inject = super::embedded_search_tools::search_injection(search_shadows);
|
||||
|
||||
// Create two OS pipes: one for state-in (fd 3), one for state-out (fd 4).
|
||||
// os_pipe() creates fds with O_CLOEXEC (atomically on Linux,
|
||||
// best-effort on macOS) so concurrent forks can't leak them.
|
||||
// One pipe for state-in (fd 3), one for state-out (fd 4).
|
||||
let (state_in_read, state_in_write) = os_pipe()?;
|
||||
let (state_out_read, state_out_write) = os_pipe()?;
|
||||
|
||||
// Ensure the parent-only ends have CLOEXEC (redundant on Linux
|
||||
// where os_pipe uses pipe2, but needed as a safety net on macOS
|
||||
// where pipe+fcntl has a small race window).
|
||||
// Redundant on Linux where os_pipe uses pipe2, but a safety net on macOS
|
||||
// where pipe+fcntl leaves a small race window.
|
||||
set_cloexec(&state_in_write)?;
|
||||
set_cloexec(&state_out_read)?;
|
||||
// The child-bound ends (state_in_read, state_out_write) also have
|
||||
// CLOEXEC from os_pipe(). This is fine: fd_mappings uses dup2()
|
||||
// which clears CLOEXEC on the target fd (3/4), so the child keeps
|
||||
// them across exec. The originals are closed on exec by CLOEXEC.
|
||||
// The child-bound ends (state_in_read, state_out_write) carry CLOEXEC too.
|
||||
// That is fine: fd_mappings uses dup2(), which clears CLOEXEC on the target
|
||||
// fd (3/4), so the child keeps those across exec while the originals close.
|
||||
|
||||
// The wrapper command:
|
||||
// 1. Read prior snapshot from fd 3, eval it (restores env/funcs/aliases/opts)
|
||||
@@ -390,18 +362,16 @@ impl ShellState {
|
||||
// 4. Exit with the user command's exit code
|
||||
let wrapper = match self.shell {
|
||||
ShellKind::Bash => format!(
|
||||
// Merge the user command's stderr into its
|
||||
// stdout via `2>&1` so the captured byte stream preserves
|
||||
// chronological write order. Without this, the bash tool's
|
||||
// separate stdout/stderr pipes (each read in lockstep)
|
||||
// emit all-of-stdout-then-all-of-stderr in a single poll
|
||||
// tick, so a command like `echo X 1>&2 && echo Y` shows
|
||||
// up as `Y\nX\n` instead of the chronological `X\nY\n`.
|
||||
// Shell-level diagnostics (eval syntax errors, etc.) still
|
||||
// land on the outer shell's stderr — those are unaffected.
|
||||
// Re-export KIGI_AGENT=1 after snapshot eval so agent-definition
|
||||
// selectors (or other values) from prior shells cannot clear the
|
||||
// agent sentinel (process env alone is insufficient).
|
||||
// `2>&1` merges the user command's stderr into its stdout so the
|
||||
// captured byte stream preserves chronological write order. With
|
||||
// separate stdout/stderr pipes read in lockstep, a single poll tick
|
||||
// emits all-of-stdout-then-all-of-stderr, so `echo X 1>&2 && echo Y`
|
||||
// surfaces as `Y\nX\n` instead of `X\nY\n`. Shell-level diagnostics
|
||||
// (eval syntax errors, etc.) still land on the outer shell's stderr.
|
||||
//
|
||||
// KIGI_AGENT=1 is re-exported after the snapshot eval so values from
|
||||
// prior shells cannot clear the agent sentinel; process env alone is
|
||||
// insufficient because the snapshot replays over it.
|
||||
"{dump_script} \
|
||||
snap=$(command cat <&3) && builtin shopt -s extglob && builtin eval -- \"$snap\" && \
|
||||
{{ builtin set +u 2>/dev/null || true; \
|
||||
@@ -488,7 +458,6 @@ pub struct PreparedCommand {
|
||||
/// or `/run/current-system/sw/bin/bash` on NixOS). See
|
||||
/// [`ShellKind::binary_path`] for the resolution cascade.
|
||||
pub binary: String,
|
||||
/// Full argument list for the shell.
|
||||
pub args: Vec<String>,
|
||||
/// Fd mappings to pass to `CommandFdExt::fd_mappings()`.
|
||||
pub fd_mappings: Vec<FdMapping>,
|
||||
@@ -496,15 +465,10 @@ pub struct PreparedCommand {
|
||||
pub state_in_write: OwnedFd,
|
||||
/// Read end of the state-output pipe. Caller reads the new dump from here after exit.
|
||||
pub state_out_read: OwnedFd,
|
||||
/// Working directory for the child process.
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Create an OS pipe, returning `(read_end, write_end)` as `OwnedFd`.
|
||||
/// Returns `(read_end, write_end)`.
|
||||
///
|
||||
/// On Linux, uses `nix::unistd::pipe2(O_CLOEXEC)` to atomically set
|
||||
/// close-on-exec, eliminating the race window between `pipe()` and
|
||||
@@ -515,31 +479,25 @@ pub struct PreparedCommand {
|
||||
/// in 10.15 but `nix`'s cfg gate hasn't caught up). Falls back to
|
||||
/// `pipe()` + `fcntl(FD_CLOEXEC)` with a best-effort race window.
|
||||
fn os_pipe() -> std::io::Result<(OwnedFd, OwnedFd)> {
|
||||
// Linux: atomic O_CLOEXEC via pipe2.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
|
||||
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))
|
||||
}
|
||||
|
||||
// macOS (and other non-Linux Unix): pipe() + fcntl best-effort.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let (read_fd, write_fd) =
|
||||
nix::unistd::pipe().map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
|
||||
// Best-effort CLOEXEC — small race window on macOS between pipe()
|
||||
// and these fcntl calls, but unavoidable without pipe2.
|
||||
let _ = set_cloexec(&read_fd);
|
||||
let _ = set_cloexec(&write_fd);
|
||||
Ok((read_fd, write_fd))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set FD_CLOEXEC on a file descriptor so it is NOT inherited by child processes.
|
||||
///
|
||||
/// This is critical for pipe fds that should stay parent-only: without CLOEXEC,
|
||||
/// the child inherits both ends of a pipe after fork, preventing EOF from being
|
||||
/// signaled when the parent closes its end.
|
||||
/// Critical for pipe fds that must stay parent-only: without FD_CLOEXEC the child
|
||||
/// inherits both ends of the pipe after fork, so closing the parent's end never
|
||||
/// signals EOF.
|
||||
fn set_cloexec(fd: &OwnedFd) -> std::io::Result<()> {
|
||||
let raw = fd.as_raw_fd();
|
||||
let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) };
|
||||
@@ -566,13 +524,13 @@ fn parse_dump(shell: ShellKind, raw: &str) -> Option<(PathBuf, String)> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Strip markers
|
||||
let without_markers = &raw[start_line.len()..raw.len() - end_line.len()];
|
||||
|
||||
// First line is $PWD
|
||||
// The dump's first line is $PWD.
|
||||
let newline_pos = without_markers.find('\n')?;
|
||||
let cwd = &without_markers[..newline_pos];
|
||||
let rest = &without_markers[newline_pos..]; // includes the leading \n
|
||||
// Keeps the leading \n.
|
||||
let rest = &without_markers[newline_pos..];
|
||||
|
||||
Some((PathBuf::from(cwd), rest.to_string()))
|
||||
}
|
||||
@@ -598,35 +556,32 @@ pub async fn write_snapshot_to_pipe(snapshot: &str, fd: OwnedFd) -> std::io::Res
|
||||
std::mem::forget(fd);
|
||||
file.write_all(data.as_bytes())?;
|
||||
file.flush()?;
|
||||
drop(file); // closes the fd → child sees EOF on its read end
|
||||
// Closing the fd is what makes the child see EOF on its read end.
|
||||
drop(file);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
/// Read the full dump output from the state-out pipe with a timeout.
|
||||
///
|
||||
/// If a background process inherits fd 4, the pipe never closes and the reader
|
||||
/// hangs. The timeout (5s close timeout) prevents this from
|
||||
/// blocking the actor loop forever. On timeout, returns whatever was read so far
|
||||
/// (which is typically empty, so marker validation will fail and prior state is kept).
|
||||
/// On timeout, returns whatever was read so far — typically empty, so marker
|
||||
/// validation fails and the caller keeps the prior state.
|
||||
pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
|
||||
// Read until either of the END markers appears, *not* until EOF.
|
||||
// Read until either END marker appears, *not* until EOF.
|
||||
//
|
||||
// When the user's command backgrounds a subprocess (`cmd &`), the bg
|
||||
// shell inherits fd 4 (the dump pipe's write-end) and keeps it open
|
||||
// until *it* exits. The parent shell finishes its dump and exits, but
|
||||
// the kernel doesn't close the read-end's EOF until every write-end
|
||||
// the kernel signals EOF on the read-end only once every write-end
|
||||
// holder closes theirs. Without marker-driven termination we'd block
|
||||
// on `read_to_string` for the entire bg lifetime, hit the 5s safety
|
||||
// on `read_to_string` for the entire bg lifetime, hit the safety
|
||||
// timeout, and discard the (perfectly complete) dump — which manifests
|
||||
// as `cd` / function / alias state silently failing to persist after
|
||||
// any command that backgrounds something. (See harness scenarios
|
||||
// "State persistence after backgrounded command" and the cd-roundtrip
|
||||
// tests for shell state persistence parity.)
|
||||
//
|
||||
// We additionally cap on `DUMP_READ_TIMEOUT` so a shell that crashed
|
||||
// `DUMP_READ_TIMEOUT` additionally caps the wait so a shell that crashed
|
||||
// before emitting the END marker doesn't wedge the actor.
|
||||
match tokio::time::timeout(
|
||||
DUMP_READ_TIMEOUT,
|
||||
@@ -639,13 +594,12 @@ pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
|
||||
loop {
|
||||
let n = file.read(&mut chunk)?;
|
||||
if n == 0 {
|
||||
// EOF: every write-end holder closed fd 4 (the
|
||||
// expected path when no bg subprocess was spawned).
|
||||
// EOF: every write-end holder closed fd 4, the expected
|
||||
// path when no bg subprocess was spawned.
|
||||
break;
|
||||
}
|
||||
buf.push_str(&String::from_utf8_lossy(&chunk[..n]));
|
||||
// Either marker suffices; we accept whichever shell the
|
||||
// child happens to be (bash vs zsh).
|
||||
// Either marker suffices; the child may be bash or zsh.
|
||||
if buf.contains(BASH_STATE_END_MARKER) || buf.contains(ZSH_STATE_END_MARKER) {
|
||||
break;
|
||||
}
|
||||
@@ -667,10 +621,6 @@ pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -753,18 +703,14 @@ mod tests {
|
||||
assert_eq!(result, output);
|
||||
}
|
||||
|
||||
/// Returns true iff a usable bash binary exists at the resolved path.
|
||||
/// Used to gate integration tests so they're skipped (rather than failing)
|
||||
/// on systems where bash isn't installed (e.g. minimal containers).
|
||||
/// On NixOS the resolver returns the nix-store / profile path, so this
|
||||
/// guard works there too.
|
||||
/// Gates the integration tests so they skip, rather than fail, on systems
|
||||
/// without bash (e.g. minimal containers). The resolved path works on NixOS
|
||||
/// too, where it points into the nix store / profile.
|
||||
fn bash_available() -> bool {
|
||||
std::path::Path::new(ShellKind::Bash.binary_path()).exists()
|
||||
}
|
||||
|
||||
/// Returns true iff a usable zsh binary exists at the resolved path.
|
||||
/// Mirrors [`bash_available`] so zsh integration tests skip (rather than
|
||||
/// fail) on systems without zsh installed.
|
||||
/// Mirrors [`bash_available`] for the zsh integration tests.
|
||||
fn zsh_available() -> bool {
|
||||
std::path::Path::new(ShellKind::Zsh.binary_path()).exists()
|
||||
}
|
||||
@@ -822,15 +768,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_bash() {
|
||||
// Integration test: actually runs bash and captures state.
|
||||
// Skip in environments without bash.
|
||||
if !bash_available() {
|
||||
return;
|
||||
}
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
assert!(state.cwd.is_absolute());
|
||||
// The snapshot should contain at least some env var exports
|
||||
assert!(
|
||||
state.snapshot.contains("kigi_snap_") || state.snapshot.is_empty(),
|
||||
"snapshot should contain encoded blocks or be empty: {:?}",
|
||||
@@ -842,14 +785,12 @@ mod tests {
|
||||
async fn test_prepare_command_and_roundtrip() {
|
||||
use command_fds::CommandFdExt;
|
||||
|
||||
// Integration test: prepare a command, spawn it, verify state roundtrip.
|
||||
if !bash_available() {
|
||||
return;
|
||||
}
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// Run "export KIGI_TEST_VAR=hello" and capture the new state
|
||||
let prep = state
|
||||
.prepare_command(
|
||||
"export KIGI_TEST_VAR=hello",
|
||||
@@ -869,19 +810,17 @@ mod tests {
|
||||
cmd.fd_mappings(prep.fd_mappings).unwrap();
|
||||
|
||||
let child = cmd.spawn().unwrap();
|
||||
// Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure.
|
||||
// Without this, the parent keeps the write-end of the state-out pipe open,
|
||||
// and the read task never sees EOF.
|
||||
// Releases the FdMapping OwnedFds held in cmd's pre_exec closure. Otherwise
|
||||
// the parent keeps the write-end of the state-out pipe open and the read
|
||||
// task never sees EOF.
|
||||
drop(cmd);
|
||||
|
||||
// Write snapshot to fd 3
|
||||
let snapshot = state.snapshot.clone();
|
||||
let write_handle =
|
||||
tokio::spawn(
|
||||
async move { write_snapshot_to_pipe(&snapshot, prep.state_in_write).await },
|
||||
);
|
||||
|
||||
// Read new dump from fd 4
|
||||
let read_handle =
|
||||
tokio::spawn(async move { read_dump_from_pipe(prep.state_out_read).await });
|
||||
|
||||
@@ -896,8 +835,8 @@ mod tests {
|
||||
"dump should have valid markers, got: {:?}",
|
||||
&dump[..dump.len().min(500)]
|
||||
);
|
||||
// The snapshot contains base64-encoded env vars, so the variable name
|
||||
// won't appear in plaintext. Verify the dump was valid and non-empty.
|
||||
// Env vars are base64-encoded in the snapshot, so the variable name never
|
||||
// appears in plaintext; a valid non-empty dump is all we can assert.
|
||||
assert!(
|
||||
!state.snapshot.is_empty(),
|
||||
"snapshot should be non-empty after a successful command"
|
||||
@@ -905,7 +844,8 @@ mod tests {
|
||||
assert!(state.cwd.is_absolute(), "cwd should be absolute");
|
||||
}
|
||||
|
||||
/// Helper: run a command against a ShellState, update state, return (exit_code, stdout).
|
||||
/// Runs a command against a ShellState, updates the state, and returns
|
||||
/// `(exit_code, stdout)`.
|
||||
async fn run_command(state: &mut ShellState, command: &str) -> (i32, String) {
|
||||
use command_fds::CommandFdExt;
|
||||
|
||||
@@ -953,11 +893,10 @@ mod tests {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// cd to /tmp (macOS resolves to /private/tmp via symlink)
|
||||
// macOS resolves /tmp to /private/tmp via symlink.
|
||||
let (code, _) = run_command(&mut state, "cd /tmp").await;
|
||||
assert_eq!(code, 0);
|
||||
|
||||
// Next command should see the resolved /tmp as cwd
|
||||
let (code, stdout) = run_command(&mut state, "pwd").await;
|
||||
assert_eq!(code, 0);
|
||||
let actual_pwd = stdout.trim();
|
||||
@@ -976,11 +915,9 @@ mod tests {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// Export a variable
|
||||
let (code, _) = run_command(&mut state, "export MY_TEST_VAR=persistent_value").await;
|
||||
assert_eq!(code, 0);
|
||||
|
||||
// Next command should see it
|
||||
let (code, stdout) = run_command(&mut state, "echo $MY_TEST_VAR").await;
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(stdout.trim(), "persistent_value");
|
||||
@@ -1079,11 +1016,9 @@ mod tests {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// Define a function
|
||||
let (code, _) = run_command(&mut state, "greet() { echo \"hello $1\"; }").await;
|
||||
assert_eq!(code, 0);
|
||||
|
||||
// Call it in the next command
|
||||
let (code, stdout) = run_command(&mut state, "greet world").await;
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(stdout.trim(), "hello world");
|
||||
@@ -1097,13 +1032,11 @@ mod tests {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// Define an alias
|
||||
let (code, _) = run_command(&mut state, "alias ll='ls -la'").await;
|
||||
assert_eq!(code, 0);
|
||||
|
||||
// Verify the alias survives by checking the dump itself (base64-encoded).
|
||||
// We can't check plaintext in the snapshot since it's base64, but we can
|
||||
// verify the snapshot is valid and non-empty (alias was captured in the dump).
|
||||
// The alias is base64-encoded inside the snapshot, so only its presence as
|
||||
// a non-empty dump can be asserted here.
|
||||
assert!(
|
||||
!state.snapshot.is_empty(),
|
||||
"snapshot should be non-empty after alias"
|
||||
@@ -1163,15 +1096,13 @@ mod tests {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
// Set up some state
|
||||
let (_, _) = run_command(&mut state, "export SURVIVE_TEST=yes").await;
|
||||
let prev_cwd = state.cwd.clone();
|
||||
|
||||
// Run a failing command — state should still update (dump runs regardless)
|
||||
// The dump runs regardless of the command's exit code.
|
||||
let (code, _) = run_command(&mut state, "false").await;
|
||||
assert_ne!(code, 0);
|
||||
|
||||
// Previous state should still be there
|
||||
assert_eq!(state.cwd, prev_cwd);
|
||||
let (_, stdout) = run_command(&mut state, "echo $SURVIVE_TEST").await;
|
||||
assert_eq!(stdout.trim(), "yes");
|
||||
|
||||
@@ -28,7 +28,6 @@ use super::SearchShadowConfig;
|
||||
#[cfg(unix)]
|
||||
use super::shell_state;
|
||||
|
||||
/// Result of spawning a shell command (persistent or plain).
|
||||
struct SpawnResult {
|
||||
child: tokio::process::Child,
|
||||
process_group: crate::util::ProcessGroup,
|
||||
@@ -41,17 +40,15 @@ const DEFAULT_NOTIFICATION_INTERVAL_MS: u64 = 100;
|
||||
const COMMAND_CHANNEL_SIZE: usize = 32;
|
||||
/// How long to keep completed background tasks in memory before eviction.
|
||||
/// The output file on disk persists for the session lifetime.
|
||||
const COMPLETED_TASK_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
||||
/// SIGTERM → SIGKILL grace period. Uses a 1-second grace.
|
||||
const COMPLETED_TASK_TTL: Duration = Duration::from_secs(300);
|
||||
/// SIGTERM → SIGKILL grace period.
|
||||
const SIGTERM_GRACE: Duration = Duration::from_secs(1);
|
||||
/// Maximum lifetime for a background task. After this, the actor
|
||||
/// will gracefully kill it. Set to 10 hours to support long
|
||||
/// background monitor and bash runs.
|
||||
/// Maximum lifetime for a background task; the actor kills it once exceeded.
|
||||
/// 10 hours, so long background monitor and bash runs survive.
|
||||
const BACKGROUND_MAX_RUNTIME: Duration = Duration::from_secs(36_000);
|
||||
/// Max time an *auto-backgroundable* foreground command blocks the turn before
|
||||
/// it's moved to the background (kept running, never killed), independent of its
|
||||
/// requested `timeout`. A short second timer for the auto-background budget.
|
||||
/// Env override: `KIGI_FOREGROUND_BLOCK_BUDGET_MS`.
|
||||
/// requested `timeout`. Env override: `KIGI_FOREGROUND_BLOCK_BUDGET_MS`.
|
||||
const FOREGROUND_BLOCK_BUDGET: Duration = Duration::from_secs(15);
|
||||
|
||||
fn foreground_block_budget_from_env() -> Duration {
|
||||
@@ -66,7 +63,7 @@ fn foreground_block_budget_from_env() -> Duration {
|
||||
/// size analogue of [`BACKGROUND_MAX_RUNTIME`], stopping an unbounded writer
|
||||
/// (`yes`, a runaway log) from filling the disk. Env override:
|
||||
/// `KIGI_MAX_OUTPUT_FILE_BYTES`.
|
||||
const MAX_OUTPUT_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
|
||||
const MAX_OUTPUT_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
fn output_file_cap_from_env() -> u64 {
|
||||
std::env::var("KIGI_MAX_OUTPUT_FILE_BYTES")
|
||||
@@ -79,24 +76,21 @@ fn output_file_cap_from_env() -> u64 {
|
||||
const DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// Max bytes retained in the output file after process exit. Truncated
|
||||
/// so `to_task_snapshot` / `read_file` don't materialize huge strings.
|
||||
const MAX_RETAINED_OUTPUT_FILE_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB
|
||||
/// Maximum number of completed-task tombstones to keep. When exceeded,
|
||||
/// the oldest entries are evicted. Each tombstone is lightweight (metadata
|
||||
/// only, no output), so 100 entries is ~10 KB.
|
||||
const MAX_RETAINED_OUTPUT_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Maximum number of completed-task tombstones to keep; the oldest entries are
|
||||
/// evicted past this. Each tombstone is metadata only (no output), ~10 KB total.
|
||||
const MAX_COMPLETED_TASK_SNAPSHOTS: usize = 100;
|
||||
|
||||
fn notification_interval() -> Duration {
|
||||
Duration::from_millis(DEFAULT_NOTIFICATION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/// Exit status of a terminal process
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ExitStatus {
|
||||
pub exit_code: Option<i32>,
|
||||
pub signal: Option<String>,
|
||||
}
|
||||
|
||||
/// Commands that can be sent to the LocalTerminalActor
|
||||
enum TerminalCommand {
|
||||
/// Foreground: spawn process, block until exit or timeout, reply with result.
|
||||
Run {
|
||||
@@ -117,7 +111,6 @@ enum TerminalCommand {
|
||||
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
||||
},
|
||||
|
||||
/// Kill a background task.
|
||||
Kill {
|
||||
task_id: String,
|
||||
reply: oneshot::Sender<KillOutcome>,
|
||||
@@ -126,26 +119,22 @@ enum TerminalCommand {
|
||||
/// Kill foregrounded processes. Called on turn cancellation.
|
||||
KillForegroundCommands,
|
||||
|
||||
/// Move a foreground command to background by tool_call_id.
|
||||
/// Unblocks the completion waiter with signal="backgrounded".
|
||||
BackgroundForeground {
|
||||
tool_call_id: String,
|
||||
reply: oneshot::Sender<bool>,
|
||||
},
|
||||
|
||||
/// Wait for a background task to finish, with optional timeout.
|
||||
WaitForCompletion {
|
||||
task_id: String,
|
||||
timeout: Option<Duration>,
|
||||
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
||||
},
|
||||
|
||||
/// List all known background tasks.
|
||||
ListTasks {
|
||||
reply: oneshot::Sender<Vec<TaskSnapshot>>,
|
||||
},
|
||||
|
||||
/// Query the persistent shell's current working directory.
|
||||
GetShellCwd {
|
||||
reply: oneshot::Sender<Option<PathBuf>>,
|
||||
},
|
||||
@@ -154,12 +143,11 @@ enum TerminalCommand {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
/// Kill all running foreground processes owned by a specific session.
|
||||
KillForegroundCommandsByOwner {
|
||||
owner_session_id: String,
|
||||
},
|
||||
|
||||
/// Kill all running background tasks owned by a specific session.
|
||||
/// Kills only the session's *background* tasks.
|
||||
KillTasksByOwner {
|
||||
owner_session_id: String,
|
||||
reply: oneshot::Sender<()>,
|
||||
@@ -180,10 +168,6 @@ enum TerminalCommand {
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-process state (for each running command)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BackgroundStatus {
|
||||
Foreground { auto_bg_on_timeout: bool },
|
||||
@@ -215,9 +199,7 @@ impl BackgroundReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// State for a single running process
|
||||
struct ProcessState {
|
||||
/// The child process
|
||||
child: tokio::process::Child,
|
||||
/// Process-tree teardown handle, shared (`Arc`) with the process-global
|
||||
/// `ProcessScope` so the TUI exit paths can reap it if this actor never runs
|
||||
@@ -239,44 +221,35 @@ struct ProcessState {
|
||||
/// Once the total char count exceeds the limit, the first half of the
|
||||
/// budget is frozen here and only the tail is kept in `output_buffer`.
|
||||
front_buffer: Option<Vec<u8>>,
|
||||
/// Whether output was truncated
|
||||
truncated: bool,
|
||||
/// Total bytes written to file (before truncation)
|
||||
/// Total bytes written to file, before any truncation.
|
||||
total_bytes: usize,
|
||||
/// Exit status once process completes
|
||||
exit_status: Option<ExitStatus>,
|
||||
/// Whether process was backgrounded and how
|
||||
bg_status: BackgroundStatus,
|
||||
/// Waiters for this process to complete (foreground only)
|
||||
/// Foreground only.
|
||||
completion_waiters: Vec<oneshot::Sender<Result<TerminalRunResult, ComputerError>>>,
|
||||
/// Configuration
|
||||
output_byte_limit: usize,
|
||||
timeout: Duration,
|
||||
/// When auto_bg_on_timeout: max FG block before auto-bg (per-request or backend default).
|
||||
foreground_block_budget: Duration,
|
||||
start_time: Instant,
|
||||
/// Path to output file (always written to)
|
||||
/// Every byte of output lands here, even for foreground commands.
|
||||
output_file: PathBuf,
|
||||
/// Open file handle for incremental writes
|
||||
file_handle: Option<File>,
|
||||
/// The command that was executed (may be isolation-wrapped)
|
||||
/// The command as executed (may be isolation-wrapped).
|
||||
command: String,
|
||||
/// Original user command before isolation wrapping (for display)
|
||||
/// Original user command before isolation wrapping, for display.
|
||||
display_command: Option<String>,
|
||||
/// Working directory where command was run
|
||||
cwd: String,
|
||||
/// Wall-clock start time (for TaskSnapshot)
|
||||
/// Wall-clock counterpart to `start_time`, for `TaskSnapshot`.
|
||||
start_wall_time: std::time::SystemTime,
|
||||
/// When the process completed (for TTL-based eviction of background tasks)
|
||||
/// Drives TTL-based eviction of background tasks.
|
||||
completed_at: Option<Instant>,
|
||||
/// Wall-clock end time (for TaskSnapshot duration calculation)
|
||||
/// Wall-clock counterpart to `completed_at`, for `TaskSnapshot` durations.
|
||||
end_wall_time: Option<std::time::SystemTime>,
|
||||
|
||||
/// Notification handle for streaming output chunks.
|
||||
notification_handle: ToolNotificationHandle,
|
||||
/// Tool call ID for correlating notifications with the tool invocation.
|
||||
tool_call_id: String,
|
||||
/// Task kind: bash or monitor.
|
||||
kind: crate::computer::types::TaskKind,
|
||||
/// Monotonic `total_bytes` at the time of the last chunk notification.
|
||||
/// Used to detect "new output since last tick" — only send a chunk
|
||||
@@ -358,7 +331,6 @@ impl ProcessState {
|
||||
}
|
||||
let half = self.output_byte_limit / 2;
|
||||
|
||||
// Capture the front half once — on the first truncation.
|
||||
if self.front_buffer.is_none() {
|
||||
let front_end = s
|
||||
.char_indices()
|
||||
@@ -368,7 +340,6 @@ impl ProcessState {
|
||||
self.front_buffer = Some(s[..front_end].as_bytes().to_vec());
|
||||
}
|
||||
|
||||
// Keep only the last `half` chars in the tail buffer.
|
||||
let tail_start_char = char_count.saturating_sub(half);
|
||||
let tail_start_byte = s
|
||||
.char_indices()
|
||||
@@ -395,8 +366,6 @@ impl ProcessState {
|
||||
self.start_time.elapsed() > self.timeout
|
||||
}
|
||||
|
||||
/// Build a snapshot of this process's current state.
|
||||
/// Uses async I/O to read output from disk for completed background tasks.
|
||||
async fn to_task_snapshot(&self, task_id: &str) -> TaskSnapshot {
|
||||
// For completed background tasks, the in-memory buffer is cleared to free
|
||||
// memory. Fall back to reading from the output file (non-blocking).
|
||||
@@ -423,8 +392,6 @@ impl ProcessState {
|
||||
cwd: self.cwd.clone(),
|
||||
start_time: self.start_wall_time,
|
||||
end_time: if self.exit_status.is_some() {
|
||||
// Use the recorded wall-clock end time if available,
|
||||
// otherwise fall back to now (process just completed this tick).
|
||||
Some(
|
||||
self.end_wall_time
|
||||
.unwrap_or_else(std::time::SystemTime::now),
|
||||
@@ -446,10 +413,6 @@ impl ProcessState {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Actor
|
||||
// ============================================================================
|
||||
|
||||
/// Waiter registered by WaitForCompletion commands.
|
||||
/// Instead of blocking the actor loop, we store the reply sender and deadline,
|
||||
/// then check on each poll tick whether to fire it.
|
||||
@@ -458,12 +421,9 @@ struct CompletionWaiter {
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
/// The actor that owns all terminal state and processes commands
|
||||
struct LocalTerminalActor {
|
||||
/// Command receiver
|
||||
cmd_rx: mpsc::Receiver<TerminalCommand>,
|
||||
|
||||
/// Cancellation token for graceful shutdown
|
||||
cancel_token: CancellationToken,
|
||||
|
||||
/// Reaper for spawned child trees. Each spawned process enrolls its
|
||||
@@ -473,10 +433,10 @@ struct LocalTerminalActor {
|
||||
/// their own to avoid latching the global.
|
||||
scope: crate::util::ProcessScope,
|
||||
|
||||
/// Active processes: task_id -> ProcessState
|
||||
/// task_id -> live process state.
|
||||
processes: HashMap<String, ProcessState>,
|
||||
|
||||
/// task_id -> list of waiters registered by WaitForCompletion commands
|
||||
/// task_id -> waiters registered by WaitForCompletion commands.
|
||||
completion_waiters: HashMap<String, Vec<CompletionWaiter>>,
|
||||
|
||||
/// Lightweight snapshots of completed background tasks that were evicted
|
||||
@@ -502,10 +462,9 @@ struct LocalTerminalActor {
|
||||
/// are moved into this cgroup so their memory is bounded.
|
||||
_cgroup_guard: CgroupGuard,
|
||||
|
||||
/// Memory-high monitor — polls for memory pressure events from the cgroup.
|
||||
/// Polls for memory pressure events from the cgroup.
|
||||
memory_monitor: MemoryMonitor,
|
||||
|
||||
/// Whether persistent shell state is enabled.
|
||||
persistent_shell: bool,
|
||||
|
||||
/// Per-backend `find`→`bfs` / `grep`→`ugrep` shadow enable state, resolved
|
||||
@@ -630,22 +589,17 @@ impl LocalTerminalActor {
|
||||
self.ensure_persistent_shell_initialized(cwd).await;
|
||||
|
||||
let shell_state = self.shell_state.as_ref().unwrap();
|
||||
// When the persistent shell already tracks a
|
||||
// model-set cwd (the model ran a `cd`), honor it unconditionally.
|
||||
// The bash tool always populates `request.working_directory` with
|
||||
// the workspace's resolved Cwd, even when no per-call override is
|
||||
// intended; treating that as "explicit override and reset" was the
|
||||
// bug that made `cd` not persist across consecutive Shell calls.
|
||||
// The shell's own tracked cwd always wins, so there is never an override.
|
||||
// The bash tool populates `request.working_directory` with the workspace's
|
||||
// resolved Cwd even when no per-call override is intended; honoring that as
|
||||
// an explicit override resets the model's `cd` on every Shell call.
|
||||
//
|
||||
// Per-call working_directory overrides arrive through the
|
||||
// shell adapter, which prefixes a subshell `(cd <wd> &&
|
||||
// …)` to the command string — that mechanism is local to a single
|
||||
// call and does NOT mutate the parent shell's `$PWD`, so we never
|
||||
// need to surface it as a `cwd_override` here.
|
||||
// Genuine per-call overrides arrive through the shell adapter, which
|
||||
// prefixes a subshell `(cd <wd> && …)` to the command string — local to one
|
||||
// call, and it does not mutate the parent shell's `$PWD`.
|
||||
let cwd_override: Option<&std::path::Path> = None;
|
||||
// Silence the unused-binding lint on the inbound `cwd` parameter:
|
||||
// it's still threaded into `spawn_command` (the non-persistent
|
||||
// fallback path) below.
|
||||
// `cwd` is unused on this path; the non-persistent path in `spawn_command`
|
||||
// still needs it, so it stays in the signature.
|
||||
let _ = cwd;
|
||||
let prep = shell_state
|
||||
.prepare_command(command, cwd_override, self.search_shadows)
|
||||
@@ -659,9 +613,8 @@ impl LocalTerminalActor {
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Apply SHELL_ENV_OVERRIDES (TERM=dumb, NO_COLOR, KIGI_AGENT=1, etc.)
|
||||
// + request env + pager env. Agent marker is re-applied last so request
|
||||
// env cannot clear it.
|
||||
// Overrides (TERM=dumb, NO_COLOR, KIGI_AGENT=1, …), then request env, then
|
||||
// pager env. The agent marker is applied last so request env cannot clear it.
|
||||
cmd.envs(shell_state::shell_env_overrides());
|
||||
|
||||
for (key, value) in env {
|
||||
@@ -697,7 +650,7 @@ impl LocalTerminalActor {
|
||||
tracing::debug!("Failed to attach persistent-shell child to ProcessGroup: {e}");
|
||||
}
|
||||
|
||||
// Write prior snapshot to fd 3 (state input pipe) in a background task.
|
||||
// fd 3 is the state input pipe.
|
||||
let snapshot = shell_state.snapshot.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
@@ -707,7 +660,7 @@ impl LocalTerminalActor {
|
||||
}
|
||||
});
|
||||
|
||||
// Read new dump from fd 4 (state output pipe) in a background task.
|
||||
// fd 4 is the state output pipe.
|
||||
let dump_handle =
|
||||
tokio::spawn(
|
||||
async move { shell_state::read_dump_from_pipe(prep.state_out_read).await },
|
||||
@@ -720,7 +673,6 @@ impl LocalTerminalActor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Main actor loop
|
||||
async fn run(mut self) {
|
||||
let mut ticker = tokio::time::interval(notification_interval());
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
@@ -732,25 +684,21 @@ impl LocalTerminalActor {
|
||||
// when poll_all_processes was slow (e.g. drain timeouts).
|
||||
biased;
|
||||
|
||||
// Check for cancellation
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
self.shutdown_all().await;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle incoming commands
|
||||
cmd = self.cmd_rx.recv() => {
|
||||
match cmd {
|
||||
Some(cmd) => self.handle_command(cmd).await,
|
||||
None => {
|
||||
// Channel closed, all senders dropped
|
||||
self.shutdown_all().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic maintenance: check timeouts, read output, etc.
|
||||
// Gated on live processes: the actor exists for the whole
|
||||
// session lifetime, and an idle session must not wake 10x/sec
|
||||
// to poll an empty map (one actor per open session/tab adds
|
||||
@@ -875,8 +823,7 @@ impl LocalTerminalActor {
|
||||
request: TerminalRunRequest,
|
||||
reply: oneshot::Sender<Result<TerminalRunResult, ComputerError>>,
|
||||
) {
|
||||
// Generate an internal ID — foreground callers never see this; the reply
|
||||
// goes back on the oneshot channel.
|
||||
// Foreground callers never see this id; the reply goes back on the oneshot.
|
||||
let internal_id = uuid::Uuid::now_v7().to_string();
|
||||
|
||||
let SpawnResult {
|
||||
@@ -901,7 +848,6 @@ impl LocalTerminalActor {
|
||||
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
|
||||
}
|
||||
|
||||
// Open output file for writing (create parent dirs if needed)
|
||||
let file_handle = match open_output_file(&request.output_file).await {
|
||||
Ok(file) => Some(file),
|
||||
Err(e) => {
|
||||
@@ -982,10 +928,10 @@ impl LocalTerminalActor {
|
||||
// exit watcher's snapshot carries the flag.
|
||||
process.explicitly_killed = true;
|
||||
|
||||
// Kill the process and finalize its state in one shot.
|
||||
let outcome = kill_and_finalize(process).await;
|
||||
|
||||
// Resolve completion waiters immediately, so callers blocked on wait_for_completion() unblock right away.
|
||||
// Resolve waiters here so callers blocked on wait_for_completion() unblock
|
||||
// without waiting for the next poll tick.
|
||||
if let Some(waiters) = self.completion_waiters.remove(terminal_id) {
|
||||
let snapshot = match self.processes.get(terminal_id) {
|
||||
Some(p) => Some(p.to_task_snapshot(terminal_id).await),
|
||||
@@ -999,8 +945,6 @@ impl LocalTerminalActor {
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Handle a background execution request.
|
||||
/// Spawns the process, registers it under a generated task_id, and replies immediately.
|
||||
async fn handle_run_background(
|
||||
&mut self,
|
||||
request: TerminalRunRequest,
|
||||
@@ -1029,7 +973,6 @@ impl LocalTerminalActor {
|
||||
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
|
||||
}
|
||||
|
||||
// Open output file for writing (create parent dirs if needed)
|
||||
let file_handle = match open_output_file(&request.output_file).await {
|
||||
Ok(file) => Some(file),
|
||||
Err(e) => {
|
||||
@@ -1042,7 +985,7 @@ impl LocalTerminalActor {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate task_id — the actor owns the identity
|
||||
// The actor owns task identity; callers only ever receive it.
|
||||
let task_id = uuid::Uuid::now_v7().to_string();
|
||||
|
||||
let process_state = ProcessState {
|
||||
@@ -1056,7 +999,7 @@ impl LocalTerminalActor {
|
||||
bg_status: BackgroundStatus::Backgrounded {
|
||||
reason: BackgroundReason::Explicit,
|
||||
},
|
||||
completion_waiters: vec![], // no foreground waiter
|
||||
completion_waiters: vec![],
|
||||
output_byte_limit: request.output_byte_limit,
|
||||
timeout: request.timeout,
|
||||
// Unused for already-backgrounded tasks; keep a defined value.
|
||||
@@ -1084,8 +1027,8 @@ impl LocalTerminalActor {
|
||||
// Detach the dump reader so its result is discarded (the task
|
||||
// continues independently until EOF or DUMP_READ_TIMEOUT).
|
||||
state_dump_handle: if self.persistent_shell {
|
||||
// Still spawn with the state wrapping (so bg commands inherit
|
||||
// the session env), but discard the dump reader.
|
||||
// The spawn still carries the state wrapping so bg commands
|
||||
// inherit the session env; only the dump reader is discarded.
|
||||
drop(state_dump_handle);
|
||||
None
|
||||
} else {
|
||||
@@ -1094,11 +1037,9 @@ impl LocalTerminalActor {
|
||||
owner_session_id: request.owner_session_id.clone(),
|
||||
};
|
||||
|
||||
// Store under task_id — this is the key that get_task/kill_task will use
|
||||
let pid = process_state.child.id();
|
||||
self.processes.insert(task_id.clone(), process_state);
|
||||
|
||||
// Reply immediately
|
||||
let _ = reply.send(Ok(BackgroundHandle {
|
||||
task_id,
|
||||
output_file: request.output_file,
|
||||
@@ -1115,14 +1056,11 @@ impl LocalTerminalActor {
|
||||
reply: oneshot::Sender<Option<TaskSnapshot>>,
|
||||
) {
|
||||
let Some(process) = self.processes.get_mut(&task_id) else {
|
||||
// Check completed snapshots (task already evicted from processes).
|
||||
// Mark `block_waited=true` in-place so any downstream consumer
|
||||
// (e.g. list_tasks, get_task) reflects that the model awaited
|
||||
// the result. Without this, a late-arriving wait would not
|
||||
// imprint the flag on the tombstone, leaving auto-wake noise
|
||||
// suppressed only on this one reply. Imprint only when the
|
||||
// waiter actually receives the reply — a dropped receiver
|
||||
// (cancelled turn) means the model never saw the result.
|
||||
// The task is already evicted from `processes`, so serve the tombstone
|
||||
// and imprint `block_waited=true` on it in place, so other readers
|
||||
// (list_tasks, get_task) also see that the model awaited the result.
|
||||
// Imprint only when the waiter actually receives the reply — a dropped
|
||||
// receiver (cancelled turn) means the model never saw it.
|
||||
let snapshot = self.completed_task_snapshots.get(&task_id).map(|s| {
|
||||
let mut s = s.clone();
|
||||
s.block_waited = true;
|
||||
@@ -1157,7 +1095,6 @@ impl LocalTerminalActor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register as a completion waiter and return control to the actor loop.
|
||||
let timeout = timeout.unwrap_or(Duration::from_secs(30));
|
||||
self.completion_waiters
|
||||
.entry(task_id)
|
||||
@@ -1166,15 +1103,11 @@ impl LocalTerminalActor {
|
||||
reply,
|
||||
deadline: Instant::now() + timeout,
|
||||
});
|
||||
|
||||
// Return immediately — actor loop resumes processing other commands.
|
||||
}
|
||||
|
||||
/// Poll all processes for output and completion
|
||||
async fn poll_all_processes(&mut self) {
|
||||
// 0a. Check if the memory monitor detected a memory.high breach.
|
||||
// If so, kill the *newest* running foreground process (kill the
|
||||
// most recent command first).
|
||||
// 0a. On a memory.high breach, kill the newest running process — the
|
||||
// most recent command is the likeliest culprit.
|
||||
if let Some(event) = self.memory_monitor.try_recv() {
|
||||
tracing::warn!(
|
||||
memory_current = event.memory_current,
|
||||
@@ -1182,7 +1115,6 @@ impl LocalTerminalActor {
|
||||
"Memory high threshold breached — killing newest running process"
|
||||
);
|
||||
|
||||
// Find the newest running (non-exited) process by start_time.
|
||||
let newest_id = self
|
||||
.processes
|
||||
.iter()
|
||||
@@ -1333,7 +1265,8 @@ impl LocalTerminalActor {
|
||||
.processes
|
||||
.get(&task_id)
|
||||
.map(|p| p.exit_status.is_some())
|
||||
.unwrap_or(true); // process gone = treat as completed
|
||||
// A process that is gone counts as completed.
|
||||
.unwrap_or(true);
|
||||
|
||||
if completed && let Some(waiters) = self.completion_waiters.remove(&task_id) {
|
||||
let snapshot = match self.processes.get(&task_id) {
|
||||
@@ -1381,7 +1314,6 @@ impl LocalTerminalActor {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove empty waiter lists
|
||||
self.completion_waiters.retain(|_, v| !v.is_empty());
|
||||
|
||||
// Clear block_waited for tasks where all waiters timed out without
|
||||
@@ -1395,8 +1327,7 @@ impl LocalTerminalActor {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Set completed_at and clear output buffer for completed background tasks
|
||||
// First pass: mark completed and clear buffers, collect IDs for notification
|
||||
// 3. Set completed_at and clear output buffer for completed background tasks.
|
||||
let mut newly_completed: Vec<String> = Vec::new();
|
||||
for (task_id, process) in self.processes.iter_mut() {
|
||||
if process.exit_status.is_some()
|
||||
@@ -1412,13 +1343,11 @@ impl LocalTerminalActor {
|
||||
newly_completed.push(task_id.clone());
|
||||
}
|
||||
}
|
||||
// Second pass: send completion notifications (requires async file read).
|
||||
//
|
||||
// The `block_waited` gate that suppresses the redundant auto-wake
|
||||
// synthetic prompt for awaited tasks lives in
|
||||
// `tools/notification_bridge.rs` (the `TaskCompleted` arm checks
|
||||
// `task_snapshot.block_waited` before the auto-wake injection
|
||||
// branch — see the comment there). This pass must still fire
|
||||
// branch — see the comment there). This loop must still fire
|
||||
// `send_task_complete` unconditionally for newly-completed
|
||||
// background tasks so the pager UI, persistence, and
|
||||
// `AutoWakeDeliveredIds` bookkeeping all still get the snapshot.
|
||||
@@ -1437,10 +1366,11 @@ impl LocalTerminalActor {
|
||||
.iter()
|
||||
.filter(|(_, p)| {
|
||||
if p.exit_status.is_none() {
|
||||
return false; // still running, keep
|
||||
return false;
|
||||
}
|
||||
// Foreground processes have already replied to their caller.
|
||||
if !p.bg_status.is_backgrounded() {
|
||||
return true; // foreground, already replied, evict
|
||||
return true;
|
||||
}
|
||||
// Backgrounded + completed: evict after TTL
|
||||
matches!(p.completed_at, Some(t) if t.elapsed() >= self.completed_task_ttl)
|
||||
@@ -1502,16 +1432,13 @@ impl LocalTerminalActor {
|
||||
// output once it exits.
|
||||
if process.exit_status.is_some() {
|
||||
if process.drained {
|
||||
// Already drained — nothing left to do for this process.
|
||||
return;
|
||||
}
|
||||
match process.child.try_wait() {
|
||||
Ok(None) => {
|
||||
// Process was told to die but is still running — escalate to SIGKILL
|
||||
send_sigkill_to_group(process);
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
// Process finally exited — drain any remaining output
|
||||
drain_remaining_output(process).await;
|
||||
process.flush_and_truncate_output_file().await;
|
||||
process.drained = true;
|
||||
@@ -1525,20 +1452,13 @@ impl LocalTerminalActor {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Non-blocking reads ──────────────────────────────────────────
|
||||
//
|
||||
// Read all *currently available* bytes from stdout and stderr using
|
||||
// non-blocking `poll_read`. This avoids the old 10 ms timeout-per-
|
||||
// stream approach which cost 20 ms per process even when idle —
|
||||
// with N processes that compounded to N×20 ms per tick, easily
|
||||
// exceeding the 100 ms tick interval and delaying file writes.
|
||||
//
|
||||
// Data from both streams is collected into `new_bytes`, then written
|
||||
// to the output file in a single batch + flush at the end.
|
||||
// Both streams are read with non-blocking `poll_read`, taking only what is
|
||||
// available right now. A timeout-per-stream read costs that timeout per
|
||||
// process on every tick even when idle, which for N processes overruns the
|
||||
// 100 ms tick interval and delays the output-file writes.
|
||||
|
||||
let mut new_bytes: Vec<u8> = Vec::new();
|
||||
|
||||
// Read all available stdout (non-blocking)
|
||||
let mut stdout_eof = false;
|
||||
if let Some(stdout) = process.child.stdout.as_mut() {
|
||||
loop {
|
||||
@@ -1555,12 +1475,11 @@ impl LocalTerminalActor {
|
||||
stdout_eof = true;
|
||||
break;
|
||||
}
|
||||
None => break, // No data available right now — move on
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read all available stderr (non-blocking)
|
||||
let mut stderr_eof = false;
|
||||
if let Some(stderr) = process.child.stderr.as_mut() {
|
||||
loop {
|
||||
@@ -1577,7 +1496,7 @@ impl LocalTerminalActor {
|
||||
stderr_eof = true;
|
||||
break;
|
||||
}
|
||||
None => break, // No data available right now — move on
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1596,10 +1515,6 @@ impl LocalTerminalActor {
|
||||
// Truncate in-memory buffer if needed (file has full output)
|
||||
process.maybe_truncate();
|
||||
|
||||
// Send output chunk notification if there's new output since last tick.
|
||||
// This happens every ~100ms (the actor's tick interval).
|
||||
// If the handle is noop(), send() silently drops — no performance cost.
|
||||
//
|
||||
// Keyed off the monotonic `total_bytes` (not `output_buffer.len()`):
|
||||
// after `maybe_truncate` freezes the front half and keeps only the
|
||||
// shrinking tail, a length-based gate would go false and stay false,
|
||||
@@ -1639,7 +1554,6 @@ impl LocalTerminalActor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for timeout.
|
||||
if process.is_timed_out() && process.exit_status.is_none() {
|
||||
if matches!(
|
||||
process.bg_status,
|
||||
@@ -1651,7 +1565,6 @@ impl LocalTerminalActor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: kill the process on timeout.
|
||||
send_sigterm_to_group(process);
|
||||
process.exit_status = Some(ExitStatus {
|
||||
exit_code: None,
|
||||
@@ -1664,14 +1577,12 @@ impl LocalTerminalActor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if process exited (both streams at EOF or process exited)
|
||||
let process_done = stdout_eof && stderr_eof;
|
||||
match process.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
// Process exited — drain any remaining stdout/stderr that arrived
|
||||
// after the timeout-based reads above. This fixes a race where fast
|
||||
// commands (e.g. `python3 -c "print('x')"`) exit before their pipe
|
||||
// buffers are read, resulting in empty output.
|
||||
// Drain what arrived after the reads above: fast commands (e.g.
|
||||
// `python3 -c "print('x')"`) exit before their pipe buffers are
|
||||
// read, and without this drain their output comes back empty.
|
||||
drain_remaining_output(process).await;
|
||||
|
||||
process.exit_status = Some(extract_exit_status(status));
|
||||
@@ -1766,9 +1677,8 @@ impl LocalTerminalActor {
|
||||
if let Some(process) = self.processes.get_mut(id) {
|
||||
send_sigkill_to_group(process);
|
||||
|
||||
// Wait for the child to actually exit so the kernel reclaims
|
||||
// its memory. Bounded to 5 s — SIGKILL is unconditional so
|
||||
// this should resolve almost instantly in practice.
|
||||
// Bounded at 5 s; SIGKILL is unconditional so this resolves
|
||||
// almost instantly in practice.
|
||||
let _ =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), process.child.wait())
|
||||
.await;
|
||||
@@ -1795,7 +1705,6 @@ impl LocalTerminalActor {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead foreground entries
|
||||
for id in &fg_ids {
|
||||
self.processes.remove(id);
|
||||
}
|
||||
@@ -1967,14 +1876,8 @@ impl LocalTerminalActor {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handle (public API)
|
||||
// ============================================================================
|
||||
|
||||
/// Handle to interact with the terminal actor.
|
||||
///
|
||||
/// This is the public API that implements `TerminalBackend`.
|
||||
/// It sends commands to the actor via channels - no mutex locks needed.
|
||||
/// Handle to the terminal actor: the public `TerminalBackend` implementation,
|
||||
/// which reaches the actor's state only by sending commands over a channel.
|
||||
#[derive(Clone)]
|
||||
pub struct LocalTerminalBackend {
|
||||
cmd_tx: mpsc::Sender<TerminalCommand>,
|
||||
@@ -1982,37 +1885,28 @@ pub struct LocalTerminalBackend {
|
||||
}
|
||||
|
||||
impl LocalTerminalBackend {
|
||||
/// Create a new LocalTerminalBackend and spawn the actor task.
|
||||
///
|
||||
/// The actor runs in a spawned task and processes commands from the channel.
|
||||
/// If `memory_config` is provided, a cgroupv2 memory limit is enforced on
|
||||
/// all spawned commands (Linux only; silently degrades to no-op elsewhere).
|
||||
/// Spawns the actor task that owns all terminal state.
|
||||
pub fn new() -> Self {
|
||||
Self::new_inner(None, false, false, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with persistent shell state.
|
||||
///
|
||||
/// When enabled, environment variables, working directory, functions, aliases,
|
||||
/// and shell options persist across command invocations. The user's login shell
|
||||
/// (bash or zsh) is detected and its rc files are loaded once on first command.
|
||||
/// Environment variables, working directory, functions, aliases, and shell
|
||||
/// options persist across command invocations. The user's login shell (bash or
|
||||
/// zsh) is detected and its rc files are loaded once on the first command.
|
||||
pub fn with_persistent_shell() -> Self {
|
||||
Self::new_inner(None, false, true, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with cgroup memory limits.
|
||||
///
|
||||
/// See [`CgroupMemoryConfig`] for details on the soft/hard limit model.
|
||||
pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), false, false, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
|
||||
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), false, true, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
|
||||
/// Runs the actor on `spawn_local`, for single-threaded runtimes.
|
||||
///
|
||||
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
||||
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
||||
@@ -2020,15 +1914,12 @@ impl LocalTerminalBackend {
|
||||
Self::new_inner(None, true, false, search_shadows)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local with persistent shell.
|
||||
///
|
||||
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
||||
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
||||
pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self {
|
||||
Self::new_inner(None, true, true, search_shadows)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
|
||||
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), true, false, SearchShadowConfig::default())
|
||||
}
|
||||
@@ -2053,7 +1944,6 @@ impl LocalTerminalBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a backend with a custom completed-task TTL (for testing).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_with_completed_task_ttl(ttl: Duration) -> Self {
|
||||
Self::new_with_ttl(
|
||||
@@ -2068,7 +1958,6 @@ impl LocalTerminalBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Backend with a custom foreground budget (test-only).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_with_foreground_budget(budget: Duration) -> Self {
|
||||
Self::new_with_ttl(
|
||||
@@ -2083,7 +1972,6 @@ impl LocalTerminalBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Backend with a custom output-file size cap (test-only).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_with_output_cap(output_file_cap: u64) -> Self {
|
||||
Self::new_with_ttl(
|
||||
@@ -2378,18 +2266,12 @@ impl TerminalBackend for LocalTerminalBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
/// Non-blocking read: returns `Some(Ok(n))` if data is available,
|
||||
/// `Some(Err(e))` on I/O error, `Some(Ok(0))` on EOF, or `None` if
|
||||
/// no data is ready right now.
|
||||
///
|
||||
/// Uses `Waker::noop()` — safe because the actor runs a periodic
|
||||
/// polling loop and doesn't need wake-up notifications from the pipe.
|
||||
/// This eliminates the 10 ms timeout-per-read that previously caused
|
||||
/// O(N × 20 ms) per-tick overhead for N processes.
|
||||
fn try_read_nonblocking(
|
||||
reader: &mut (impl tokio::io::AsyncRead + Unpin),
|
||||
buf: &mut [u8],
|
||||
@@ -2500,16 +2382,14 @@ async fn drain_remaining_output(process: &mut ProcessState) {
|
||||
async fn graceful_kill_and_wait(process: &mut ProcessState) {
|
||||
send_sigterm_to_group(process);
|
||||
|
||||
// Wait up to SIGTERM_GRACE (1s) for graceful exit
|
||||
if tokio::time::timeout(SIGTERM_GRACE, process.child.wait())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
drain_remaining_output(process).await;
|
||||
return; // exited cleanly
|
||||
return;
|
||||
}
|
||||
|
||||
// Escalate to SIGKILL
|
||||
send_sigkill_to_group(process);
|
||||
|
||||
// Wait for reap — bounded at 5s. SIGKILL is unconditional so this
|
||||
@@ -2555,10 +2435,10 @@ async fn kill_and_finalize(process: &mut ProcessState) -> KillOutcome {
|
||||
finalize_process(process, None).await;
|
||||
return KillOutcome::AlreadyExited;
|
||||
}
|
||||
Ok(None) => {} // still running, proceed to kill
|
||||
// Still running, proceed to kill.
|
||||
Ok(None) => {}
|
||||
}
|
||||
|
||||
// Two-phase kill: SIGTERM → 1s grace → SIGKILL (bounded waits)
|
||||
graceful_kill_and_wait(process).await;
|
||||
|
||||
// The child is reaped now, so drop the scope's reaping handle immediately
|
||||
@@ -2581,7 +2461,6 @@ async fn kill_and_finalize(process: &mut ProcessState) -> KillOutcome {
|
||||
KillOutcome::Killed
|
||||
}
|
||||
|
||||
/// Set exit_status, flush the output file, and notify foreground waiters.
|
||||
async fn finalize_process(process: &mut ProcessState, status: Option<std::process::ExitStatus>) {
|
||||
if process.exit_status.is_some() {
|
||||
return;
|
||||
@@ -2607,7 +2486,6 @@ async fn finalize_process(process: &mut ProcessState, status: Option<std::proces
|
||||
/// Open an output file for writing, creating parent directories if needed.
|
||||
#[tracing::instrument(name = "fs.open_output_file", skip_all)]
|
||||
async fn open_output_file(path: &std::path::Path) -> std::io::Result<File> {
|
||||
// Create parent directories if they don't exist
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
@@ -2738,7 +2616,7 @@ fn spawn_shell_command(
|
||||
// detach_from_tty() handles both session and process group creation.
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars).
|
||||
// Request env carries .envrc, color vars, ACP-provided vars.
|
||||
cmd.envs(shell_state::shell_env_overrides());
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
@@ -2870,10 +2748,6 @@ fn extract_exit_status(status: std::process::ExitStatus) -> ExitStatus {
|
||||
ExitStatus { exit_code, signal }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2881,7 +2755,6 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_request(command: &str) -> TerminalRunRequest {
|
||||
// Use a unique temp file for each test
|
||||
let output_file = std::env::temp_dir().join(format!(
|
||||
"terminal-test-{}-{}.out",
|
||||
std::process::id(),
|
||||
@@ -3282,7 +3155,8 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("terminal-test-size-{}.out", std::process::id()));
|
||||
|
||||
let request = TerminalRunRequest {
|
||||
command: "yes".to_string(), // floods stdout forever
|
||||
// `yes` floods stdout forever.
|
||||
command: "yes".to_string(),
|
||||
working_directory: PathBuf::from("/tmp"),
|
||||
env: HashMap::new(),
|
||||
// Long timeout: the SIZE guard, not the timeout, must fire.
|
||||
@@ -3442,10 +3316,6 @@ mod tests {
|
||||
let _ = tokio::fs::remove_file(&output_file).await;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// BashOutputChunk streaming tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunk_notifications_sent_during_execution() {
|
||||
// Create a real notification channel (not noop)
|
||||
@@ -3772,10 +3642,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CS2: Graceful kill tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_timeout_uses_sigterm_then_sigkill() {
|
||||
// Run a command with a short timeout. Verify the result has timed_out == true
|
||||
@@ -3804,10 +3670,6 @@ mod tests {
|
||||
assert_eq!(result.signal.as_deref(), Some("timeout"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CS3: Output drain tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "flaky: output buffer sometimes not flushed before kill completes"]
|
||||
async fn test_output_preserved_on_kill() {
|
||||
@@ -3930,10 +3792,6 @@ mod tests {
|
||||
assert_eq!(DRAIN_TIMEOUT, Duration::from_secs(2));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CS4: Background guardrails tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sigterm_grace_is_one_second() {
|
||||
// Static assertion: guard against someone changing the constant
|
||||
@@ -3946,10 +3804,6 @@ mod tests {
|
||||
assert_eq!(BACKGROUND_MAX_RUNTIME, Duration::from_secs(36_000));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CS1: Process group tests (pre-existing)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "flaky: pgrep sees sleep processes from other sandbox co-tenants"]
|
||||
async fn test_kill_kills_child_processes() {
|
||||
@@ -4161,10 +4015,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Persistent shell tests
|
||||
// ================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persistent_shell_cd_persists() {
|
||||
let backend = LocalTerminalBackend::with_persistent_shell();
|
||||
@@ -4339,10 +4189,6 @@ mod tests {
|
||||
assert!(snap_wait.completed);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Auto-wake suppression tests (TOCTOU race fixes)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Fix 3 — when wait_for_completion is called AFTER the task was
|
||||
/// evicted from `processes` (snapshot-only branch), the returned
|
||||
/// snapshot must reflect `block_waited=true` AND the in-place
|
||||
@@ -4535,10 +4381,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Owner-scoped kill and reparent tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: create a request owned by a specific session.
|
||||
fn make_owned_request(command: &str, owner: &str) -> TerminalRunRequest {
|
||||
let mut req = make_request(command);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod local;
|
||||
/// Contains the computer implementation
|
||||
pub mod types;
|
||||
|
||||
@@ -7,10 +7,6 @@ use std::{
|
||||
|
||||
use crate::notification::types::ToolNotificationHandle;
|
||||
|
||||
// ============================================================================
|
||||
// Error types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone)]
|
||||
pub enum ComputerError {
|
||||
#[error("IO Error: {0}")]
|
||||
@@ -20,7 +16,6 @@ pub enum ComputerError {
|
||||
}
|
||||
|
||||
impl ComputerError {
|
||||
/// Create an IO error from a message string with no preserved error kind.
|
||||
pub fn io(msg: impl Into<String>) -> Self {
|
||||
Self::IOError(msg.into(), None)
|
||||
}
|
||||
@@ -29,7 +24,6 @@ impl ComputerError {
|
||||
Self::IOError(msg.into(), Some(kind))
|
||||
}
|
||||
|
||||
/// Returns the underlying `io::ErrorKind` or `None`
|
||||
pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
|
||||
match self {
|
||||
Self::IOError(_, kind) => *kind,
|
||||
@@ -44,10 +38,6 @@ impl From<std::io::Error> for ComputerError {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File system trait
|
||||
// ============================================================================
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait AsyncFileSystem: Send + Sync {
|
||||
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError>;
|
||||
@@ -57,38 +47,28 @@ pub trait AsyncFileSystem: Send + Sync {
|
||||
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Terminal types
|
||||
// ============================================================================
|
||||
|
||||
pub struct TerminalRunRequest {
|
||||
pub command: String,
|
||||
pub working_directory: PathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
pub timeout: Duration,
|
||||
pub output_byte_limit: usize,
|
||||
/// File path to write output incrementally as it arrives.
|
||||
/// This ensures full output is always available even after in-memory buffer is truncated.
|
||||
/// For background tasks, this allows retrieval of output after the agent has moved on.
|
||||
/// Output is written here incrementally, so the full text stays retrievable
|
||||
/// after the in-memory buffer is truncated or the agent has moved on.
|
||||
pub output_file: PathBuf,
|
||||
|
||||
/// Notification handle for streaming output chunks during execution.
|
||||
/// The backend sends `BashOutputChunk` notifications every ~100ms.
|
||||
/// Callers that don't need streaming pass `ToolNotificationHandle::noop()`
|
||||
/// — messages are silently dropped. No `Option` wrapper needed.
|
||||
/// Receives `BashOutputChunk` notifications every ~100ms during execution.
|
||||
/// Callers that don't want streaming pass `ToolNotificationHandle::noop()`,
|
||||
/// which drops them — hence no `Option` wrapper.
|
||||
pub notification_handle: ToolNotificationHandle,
|
||||
|
||||
/// Tool call ID for correlating notifications with the tool invocation.
|
||||
/// Flows from `ToolContext::tool_call_id()` through the actor to
|
||||
/// `BashOutputChunk.base.tool_call_id`.
|
||||
pub tool_call_id: String,
|
||||
|
||||
/// Original user command before isolation wrapping.
|
||||
///
|
||||
/// When set, the terminal actor stores this on the `ProcessEntry` so
|
||||
/// `get_task()` returns it in `TaskSnapshot.display_command`. This
|
||||
/// ensures model-facing `get_task_output` shows the user's command
|
||||
/// instead of the `unshare`/mount wrapper.
|
||||
/// Original user command before isolation wrapping. Surfaces through
|
||||
/// `TaskSnapshot.display_command` so model-facing `get_task_output` shows
|
||||
/// the user's command instead of the `unshare`/mount wrapper.
|
||||
pub display_command: Option<String>,
|
||||
|
||||
/// Auto-background on timeout instead of killing (default `false`).
|
||||
@@ -103,16 +83,14 @@ pub struct TerminalRunRequest {
|
||||
/// - `Some(d)` → auto-bg after `d` if still running.
|
||||
pub foreground_block_budget: Option<Duration>,
|
||||
|
||||
/// Task kind for distinguishing monitor tasks from regular bash tasks.
|
||||
pub kind: TaskKind,
|
||||
|
||||
/// Session that owns this process. Used to scope kill operations so
|
||||
/// `kill_all_background_tasks_by_owner` only targets the requesting
|
||||
/// session's processes — not the parent's or sibling's.
|
||||
/// Scopes kill operations so `kill_all_background_tasks_by_owner` only
|
||||
/// targets the requesting session's processes — not the parent's or
|
||||
/// a sibling's.
|
||||
pub owner_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Distinguishes different types of background tasks.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
@@ -126,7 +104,6 @@ pub struct TerminalRunRequest {
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskKind {
|
||||
/// Regular bash command.
|
||||
#[default]
|
||||
Bash,
|
||||
/// Monitor tool — streams stdout events with rate limiting.
|
||||
@@ -140,47 +117,39 @@ pub struct TerminalRunResult {
|
||||
pub truncated: bool,
|
||||
pub signal: Option<String>,
|
||||
pub timed_out: bool,
|
||||
/// Path to the output file where full output is stored.
|
||||
/// Use read_file tool to retrieve full output when truncated.
|
||||
/// Holds the full output; read it back with the read_file tool when
|
||||
/// `combined_output` is truncated.
|
||||
pub output_file: PathBuf,
|
||||
/// Total bytes of output (before truncation).
|
||||
/// When truncated, combined_output contains the first and last portions up to output_byte_limit chars.
|
||||
/// Byte count before truncation. When truncated, `combined_output` holds
|
||||
/// the first and last portions up to `output_byte_limit` chars.
|
||||
pub total_bytes: usize,
|
||||
/// PID of the spawned shell process, when available. Set by the
|
||||
/// local terminal backend at spawn time. Useful for foreground
|
||||
/// commands that auto-background on timeout: the resulting
|
||||
/// `BackgroundTaskStarted` can carry the real PID instead of a
|
||||
/// placeholder. `None` for backends that cannot surface a local
|
||||
/// PID (e.g. ACP/remote terminals) or when the process exited
|
||||
/// before `child.id()` could be queried.
|
||||
/// PID of the spawned shell process. Lets a foreground command that
|
||||
/// auto-backgrounds on timeout report a real PID rather than a placeholder.
|
||||
/// `None` for backends without a local PID (e.g. ACP/remote terminals) or
|
||||
/// when the process exited before `child.id()` could be queried.
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Returned by `TerminalBackend::run_background` — gives the caller the task_id
|
||||
/// to use for subsequent queries via `get_task`, `kill_task`, `wait_for_completion`.
|
||||
/// Returned by `TerminalBackend::run_background` — the `task_id` is the key for
|
||||
/// subsequent `get_task`, `kill_task`, `wait_for_completion` calls.
|
||||
pub struct BackgroundHandle {
|
||||
pub task_id: String,
|
||||
pub output_file: PathBuf,
|
||||
/// PID of the spawned shell process, when available. `None` for
|
||||
/// backends that do not surface a local PID (e.g. ACP/remote
|
||||
/// gateways) or when the process exited before the PID could be
|
||||
/// captured.
|
||||
/// `None` for backends without a local PID (e.g. ACP/remote gateways) or
|
||||
/// when the process exited before the PID could be captured.
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Full snapshot of a task's state.
|
||||
/// Used by both local and ACP backends.
|
||||
#[derive(
|
||||
Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
pub struct TaskSnapshot {
|
||||
pub task_id: String,
|
||||
/// The actual command that was executed (may be isolation-wrapped).
|
||||
/// The command as executed, which may be isolation-wrapped.
|
||||
pub command: String,
|
||||
/// The original user command before isolation wrapping.
|
||||
///
|
||||
/// When set, model/user-facing output should prefer this over `command`
|
||||
/// to avoid exposing internal isolation mechanics (unshare/mount wrapper).
|
||||
/// The original user command before isolation wrapping. Model/user-facing
|
||||
/// output should prefer this over `command` to avoid exposing internal
|
||||
/// isolation mechanics (unshare/mount wrapper).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_command: Option<String>,
|
||||
pub cwd: String,
|
||||
@@ -192,33 +161,29 @@ pub struct TaskSnapshot {
|
||||
pub exit_code: Option<i32>,
|
||||
pub signal: Option<String>,
|
||||
pub completed: bool,
|
||||
/// Task kind: bash (default) or monitor.
|
||||
#[serde(default)]
|
||||
pub kind: TaskKind,
|
||||
/// Whether a block-waiter (`block=true`) consumed this task's result.
|
||||
/// When set, the notification bridge skips auto-wake synthetic prompts
|
||||
/// because the blocking caller already received the result directly.
|
||||
/// Set when a block-waiter (`block=true`) consumed this task's result, so
|
||||
/// the notification bridge skips auto-wake synthetic prompts — the blocking
|
||||
/// caller already received the result directly.
|
||||
#[serde(default)]
|
||||
pub block_waited: bool,
|
||||
/// Whether this task was explicitly killed via the `kill_command_or_subagent` tool.
|
||||
/// When set, auto-wake synthetic prompts are suppressed because the model
|
||||
/// already received the kill result via `KillTaskResult`.
|
||||
/// Also set during `kill_all_background_tasks` teardown (e.g. subagent
|
||||
/// cleanup), where auto-wake suppression is irrelevant since the session
|
||||
/// is shutting down.
|
||||
/// Set when the task was killed via the `kill_command_or_subagent` tool,
|
||||
/// suppressing auto-wake synthetic prompts because the model already
|
||||
/// received the kill result via `KillTaskResult`. Also set during
|
||||
/// `kill_all_background_tasks` teardown (e.g. subagent cleanup), where
|
||||
/// auto-wake suppression is irrelevant since the session is shutting down.
|
||||
#[serde(default)]
|
||||
pub explicitly_killed: bool,
|
||||
|
||||
/// Session that owns this task. Used for scoped kill operations so
|
||||
/// subagent teardown only kills the subagent's own tasks, not
|
||||
/// the parent's or sibling's.
|
||||
/// Scopes kill operations so subagent teardown only kills the subagent's
|
||||
/// own tasks, not the parent's or a sibling's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TaskSnapshot {
|
||||
/// Calculate duration in seconds.
|
||||
/// If task is still running, returns time since start.
|
||||
/// For a task still running, this is the time since start.
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
let end = self.end_time.unwrap_or_else(std::time::SystemTime::now);
|
||||
end.duration_since(self.start_time)
|
||||
@@ -226,10 +191,8 @@ impl TaskSnapshot {
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// True iff the task has NOT yet completed — covers bash AND
|
||||
/// monitor task kinds (the `kind` field doesn't change this
|
||||
/// predicate; the runtime turn-end TodoGate counts both as
|
||||
/// backing work).
|
||||
/// Deliberately kind-agnostic: the runtime turn-end TodoGate counts both
|
||||
/// bash and monitor tasks as backing work.
|
||||
pub fn is_outstanding(&self) -> bool {
|
||||
!self.completed
|
||||
}
|
||||
@@ -248,10 +211,6 @@ pub enum KillOutcome {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TerminalBackend trait
|
||||
// ============================================================================
|
||||
|
||||
/// The single abstraction over terminal execution backends.
|
||||
///
|
||||
/// Implemented by:
|
||||
@@ -259,48 +218,41 @@ pub enum KillOutcome {
|
||||
/// - `AcpTerminalBackend` (in kigi-shell, calls ACP protocol)
|
||||
#[async_trait::async_trait]
|
||||
pub trait TerminalBackend: Send + Sync {
|
||||
/// Run a command. Blocks until completion or timeout.
|
||||
/// Blocks until completion or timeout.
|
||||
async fn run(&self, request: TerminalRunRequest) -> Result<TerminalRunResult, ComputerError>;
|
||||
|
||||
/// Start a command in the background. Returns immediately with a handle ID.
|
||||
/// The process continues running; use get_task/kill_task/wait_for_completion to manage it.
|
||||
/// Returns immediately while the process keeps running; manage it via
|
||||
/// `get_task`/`kill_task`/`wait_for_completion`.
|
||||
async fn run_background(
|
||||
&self,
|
||||
request: TerminalRunRequest,
|
||||
) -> Result<BackgroundHandle, ComputerError>;
|
||||
|
||||
/// Get current snapshot of a background task.
|
||||
async fn get_task(&self, task_id: &str) -> Option<TaskSnapshot>;
|
||||
|
||||
/// Kill a background task.
|
||||
async fn kill_task(&self, task_id: &str) -> KillOutcome;
|
||||
|
||||
/// Kill all running foreground processes.
|
||||
async fn kill_foreground_commands(&self) {}
|
||||
|
||||
/// Kill all running foreground processes owned by a specific session.
|
||||
/// Used on a shared terminal backend so a subagent's cancel doesn't
|
||||
/// kill the parent's foreground commands.
|
||||
/// Owner-scoped variant for a shared terminal backend, so a subagent's
|
||||
/// cancel doesn't kill the parent's foreground commands.
|
||||
async fn kill_foreground_commands_by_owner(&self, _owner_session_id: &str) {}
|
||||
|
||||
/// Kill all running background tasks.
|
||||
/// Used during subagent teardown to clean up orphaned processes.
|
||||
async fn kill_all_background_tasks(&self) {}
|
||||
|
||||
/// Kill all running background tasks owned by a specific session.
|
||||
/// Used during subagent teardown on a shared terminal backend so
|
||||
/// only the subagent's own tasks are killed — not the parent's.
|
||||
/// Owner-scoped variant for a shared terminal backend, so subagent teardown
|
||||
/// kills only the subagent's own tasks — not the parent's.
|
||||
async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {}
|
||||
|
||||
/// Fire-and-forget prewarm of the persistent login shell; default no-op for
|
||||
/// backends without one (ACP/remote, non-persistent).
|
||||
async fn warm_persistent_shell(&self, _cwd: &std::path::Path) {}
|
||||
|
||||
/// Reparent notification handles for all tasks owned by `old_owner_session_id`.
|
||||
/// Swaps the dead child session's notification handle with the parent's
|
||||
/// live handle so events from surviving processes route correctly.
|
||||
/// Also re-spawns monitor pipelines on the caller's runtime so monitor
|
||||
/// events continue streaming to the parent.
|
||||
/// Swaps the dead child session's notification handle for the parent's live
|
||||
/// handle on every task owned by `old_owner_session_id`, so events from
|
||||
/// surviving processes keep routing correctly. Also re-spawns monitor
|
||||
/// pipelines on the caller's runtime so monitor events reach the parent.
|
||||
///
|
||||
/// `backend_weak` is a [`Weak`](std::sync::Weak) to *this* backend (anchored
|
||||
/// by the parent session's `Arc`); it drives re-spawned monitor pipelines
|
||||
@@ -314,37 +266,29 @@ pub trait TerminalBackend: Send + Sync {
|
||||
) {
|
||||
}
|
||||
|
||||
/// Move a foreground command to background by tool_call_id.
|
||||
/// The process keeps running but the foreground waiter is unblocked.
|
||||
/// Returns `true` if a matching foreground process was found.
|
||||
/// Unblocks the foreground waiter for `tool_call_id` while the process keeps
|
||||
/// running. Returns `true` if a matching foreground process was found.
|
||||
async fn background_foreground_command(&self, _tool_call_id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Wait for a background task to complete, with optional timeout.
|
||||
async fn wait_for_completion(
|
||||
&self,
|
||||
task_id: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Option<TaskSnapshot>;
|
||||
|
||||
/// List all known background tasks (running and completed).
|
||||
/// Used for context compaction to include task state in summaries.
|
||||
/// Includes completed tasks; context compaction uses this to put task state
|
||||
/// into summaries.
|
||||
async fn list_tasks(&self) -> Vec<TaskSnapshot>;
|
||||
|
||||
/// Return the persistent shell's current working directory, if persistent
|
||||
/// shell state is enabled. Returns `None` when persistence is off or the
|
||||
/// backend doesn't support it (e.g. ACP/remote).
|
||||
/// `None` when persistent shell state is off or unsupported by the backend
|
||||
/// (e.g. ACP/remote).
|
||||
async fn get_shell_cwd(&self) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Computer struct
|
||||
// ============================================================================
|
||||
|
||||
/// Contains the computer struct which provides access to both the terminal and the fs
|
||||
pub struct Computer {
|
||||
pub terminal: Arc<dyn TerminalBackend>,
|
||||
pub file_system: Arc<dyn AsyncFileSystem>,
|
||||
@@ -406,12 +350,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn io_with_kind_matches_local_fs_dispatch_for_not_found() {
|
||||
// Simulate what LocalFs produces for a missing file
|
||||
// What LocalFs produces for a missing file.
|
||||
let local_err = ComputerError::from(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"No such file or directory (os error 2)",
|
||||
));
|
||||
// Simulate what AcpFsAdapter now produces for RESOURCE_NOT_FOUND
|
||||
// What AcpFsAdapter produces for RESOURCE_NOT_FOUND.
|
||||
let acp_err =
|
||||
ComputerError::io_with_kind("Resource not found", std::io::ErrorKind::NotFound);
|
||||
|
||||
|
||||
@@ -8,13 +8,9 @@
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::path::Path;
|
||||
|
||||
/// Check if a path is ignored by the given gitignore rules.
|
||||
///
|
||||
/// Strips `git_root` prefix before matching — gitignore patterns are
|
||||
/// Strips the `git_root` prefix before matching — gitignore patterns are
|
||||
/// repo-relative, so `/repo/build/out.o` becomes `build/out.o` when
|
||||
/// `git_root` is `/repo`.
|
||||
///
|
||||
/// This is a pure function — no filesystem access, just `Gitignore::matched()`.
|
||||
pub fn is_ignored(gitignore: &Gitignore, path: &Path, git_root: Option<&Path>) -> bool {
|
||||
let check_path = match git_root {
|
||||
Some(root) => match path.strip_prefix(root) {
|
||||
@@ -78,9 +74,7 @@ mod tests {
|
||||
let root = tmp.path();
|
||||
let root = &dunce::canonicalize(root).unwrap();
|
||||
let gi = build_gitignore(root, &["build/"]);
|
||||
// With root: strips prefix, matches build/out.o
|
||||
assert!(is_ignored(&gi, &root.join("build/out.o"), Some(root)));
|
||||
// Without root: relative path still matches
|
||||
assert!(is_ignored(
|
||||
&gi,
|
||||
&std::path::PathBuf::from("build/out.o"),
|
||||
@@ -94,8 +88,6 @@ mod tests {
|
||||
let root = tmp.path();
|
||||
let root = &dunce::canonicalize(root).unwrap();
|
||||
let gi = build_gitignore(root, &["build/", "*.md"]);
|
||||
// A path completely outside the git root should not be checked
|
||||
// against the repo's .gitignore (e.g., ~/.kigi/Agents.md).
|
||||
let outside_path = std::path::PathBuf::from("/some/other/path/Agents.md");
|
||||
assert!(!is_ignored(&gi, &outside_path, Some(root)));
|
||||
}
|
||||
@@ -115,7 +107,6 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Our wrapper guards against it.
|
||||
assert!(!is_ignored(&gi, abs_path, None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ use super::errors::ApplyPatchError;
|
||||
use super::parser::UpdateFileChunk;
|
||||
use super::seek_sequence::seek_sequence;
|
||||
|
||||
/// Given the original file content as a `&str` and the list of update chunks,
|
||||
/// compute and return the new file contents as a `String`.
|
||||
///
|
||||
/// This is the main entry point for the apply logic. It does NOT read from or
|
||||
/// write to the filesystem.
|
||||
/// Main entry point for the apply logic.
|
||||
pub fn derive_new_contents(
|
||||
original_content: &str,
|
||||
path: &Path,
|
||||
@@ -31,7 +27,7 @@ pub fn derive_new_contents(
|
||||
let replacements = compute_replacements(&original_lines, path, chunks)?;
|
||||
let mut new_lines = apply_replacements(original_lines, &replacements);
|
||||
|
||||
// Ensure the file ends with a trailing newline.
|
||||
// A trailing empty element makes `join` emit the final newline.
|
||||
if !new_lines.last().is_some_and(String::is_empty) {
|
||||
new_lines.push(String::new());
|
||||
}
|
||||
@@ -39,9 +35,7 @@ pub fn derive_new_contents(
|
||||
Ok(new_lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Compute a list of replacements needed to transform `original_lines` into the
|
||||
/// new lines, given the patch `chunks`. Each replacement is returned as
|
||||
/// `(start_index, old_len, new_lines)`.
|
||||
/// Each replacement is `(start_index, old_len, new_lines)`.
|
||||
pub fn compute_replacements(
|
||||
original_lines: &[String],
|
||||
path: &Path,
|
||||
@@ -51,8 +45,6 @@ pub fn compute_replacements(
|
||||
let mut line_index: usize = 0;
|
||||
|
||||
for chunk in chunks {
|
||||
// If a chunk has a `change_context`, use seek_sequence to find it,
|
||||
// then adjust our `line_index` to continue from there.
|
||||
if let Some(ctx_line) = &chunk.change_context {
|
||||
if let Some(idx) = seek_sequence(
|
||||
original_lines,
|
||||
@@ -71,8 +63,8 @@ pub fn compute_replacements(
|
||||
}
|
||||
|
||||
if chunk.old_lines.is_empty() {
|
||||
// Pure addition (no old lines). Add at the end or just before the
|
||||
// final empty line if one exists.
|
||||
// Pure addition: append, but stay ahead of the trailing newline
|
||||
// sentinel so the file doesn't grow a blank line at the end.
|
||||
let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
|
||||
original_lines.len() - 1
|
||||
} else {
|
||||
@@ -82,9 +74,8 @@ pub fn compute_replacements(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to match the existing lines in the file with the old lines from
|
||||
// the chunk. If the pattern ends with a trailing empty string (final
|
||||
// newline), retry without it.
|
||||
// A pattern ending in an empty string (the chunk's final newline) may
|
||||
// not match a file that lacks one, so it is retried without it below.
|
||||
let mut pattern: &[String] = &chunk.old_lines;
|
||||
let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
|
||||
|
||||
@@ -115,9 +106,6 @@ pub fn compute_replacements(
|
||||
Ok(replacements)
|
||||
}
|
||||
|
||||
/// Apply the `(start_index, old_len, new_lines)` replacements to
|
||||
/// `original_lines`, returning the modified file contents as a vector of lines.
|
||||
///
|
||||
/// Replacements are applied in **reverse order** so that earlier replacements
|
||||
/// don't shift the positions of later ones.
|
||||
pub fn apply_replacements(
|
||||
@@ -128,14 +116,12 @@ pub fn apply_replacements(
|
||||
let start_idx = *start_idx;
|
||||
let old_len = *old_len;
|
||||
|
||||
// Remove old lines.
|
||||
for _ in 0..old_len {
|
||||
if start_idx < lines.len() {
|
||||
lines.remove(start_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new lines.
|
||||
for (offset, new_line) in new_segment.iter().enumerate() {
|
||||
lines.insert(start_idx + offset, new_line.clone());
|
||||
}
|
||||
@@ -144,8 +130,6 @@ pub fn apply_replacements(
|
||||
lines
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
@@ -153,7 +137,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::implementations::codex::apply_patch::parser::{Hunk, parse_patch};
|
||||
|
||||
/// Helper to construct a patch string with the given body.
|
||||
fn wrap_patch(body: &str) -> String {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors encountered while parsing a patch.
|
||||
#[derive(Debug, PartialEq, Clone, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("invalid patch: {0}")]
|
||||
@@ -13,10 +12,8 @@ pub enum ParseError {
|
||||
InvalidHunkError { message: String, line_number: usize },
|
||||
}
|
||||
|
||||
/// Errors encountered while applying a parsed patch to file contents.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApplyPatchError {
|
||||
/// The patch text could not be parsed.
|
||||
#[error(transparent)]
|
||||
Parse(#[from] ParseError),
|
||||
|
||||
@@ -24,9 +21,8 @@ pub enum ApplyPatchError {
|
||||
#[error("{0}")]
|
||||
ComputeReplacements(String),
|
||||
|
||||
/// An I/O error occurred while reading or writing a file.
|
||||
/// Stored as a string so that the type remains `PartialEq`-friendly in
|
||||
/// tests (std::io::Error is not PartialEq).
|
||||
/// The underlying `std::io::Error` is flattened into strings so that this
|
||||
/// enum can implement `PartialEq` for tests.
|
||||
#[error("{context}: {message}")]
|
||||
Io {
|
||||
context: String,
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
//! Codex `apply_patch` — core patch engine (pure library, no I/O).
|
||||
//! Codex `apply_patch`.
|
||||
//!
|
||||
//! This module ports the codex patch parser, fuzzy matcher, and replacement
|
||||
//! logic as pure functions with zero filesystem dependencies. All I/O
|
||||
//! (reading/writing files) is handled by the tool layer in a later milestone.
|
||||
//!
|
||||
//! # Submodules
|
||||
//!
|
||||
//! - [`apply`] — `derive_new_contents()`, `compute_replacements()`,
|
||||
//! `apply_replacements()` — all accept `&str` input.
|
||||
//! - [`errors`] — `ApplyPatchError`, `ParseError`.
|
||||
//! - [`parser`] — `parse_patch()`, `Hunk`, `UpdateFileChunk`.
|
||||
//! - [`seek_sequence`] — 4-tier fuzzy line matcher.
|
||||
//! Ports the codex patch parser, fuzzy matcher, and replacement logic. The
|
||||
//! [`parser`], [`seek_sequence`], and [`apply`] layers are pure functions over
|
||||
//! `&str`; [`tool`] is the only layer that touches the filesystem.
|
||||
|
||||
pub mod apply;
|
||||
pub mod errors;
|
||||
@@ -18,7 +10,6 @@ pub mod parser;
|
||||
pub mod seek_sequence;
|
||||
pub mod tool;
|
||||
|
||||
// Re-exports for convenience.
|
||||
pub use apply::derive_new_contents;
|
||||
pub use errors::{ApplyPatchError, ParseError};
|
||||
pub use parser::{Hunk, ParsedPatch, UpdateFileChunk, parse_patch};
|
||||
|
||||
@@ -33,8 +33,6 @@ use std::path::PathBuf;
|
||||
use super::errors::ParseError;
|
||||
use ParseError::*;
|
||||
|
||||
// ─── Marker constants ────────────────────────────────────────────────
|
||||
|
||||
const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
|
||||
const END_PATCH_MARKER: &str = "*** End Patch";
|
||||
const ADD_FILE_MARKER: &str = "*** Add File: ";
|
||||
@@ -45,19 +43,16 @@ const EOF_MARKER: &str = "*** End of File";
|
||||
const CHANGE_CONTEXT_MARKER: &str = "@@ ";
|
||||
const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
|
||||
|
||||
/// We always use lenient mode (matching the codex default).
|
||||
/// Lenient mode matches the codex default.
|
||||
const PARSE_IN_STRICT_MODE: bool = false;
|
||||
|
||||
// ─── Public types ────────────────────────────────────────────────────
|
||||
|
||||
/// A parsed patch: the list of hunks plus the normalised patch text.
|
||||
/// `patch` is the normalised patch text: trimmed and re-joined with `\n`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ParsedPatch {
|
||||
pub hunks: Vec<Hunk>,
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
/// A single hunk within a parsed patch.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum Hunk {
|
||||
@@ -77,7 +72,6 @@ pub enum Hunk {
|
||||
},
|
||||
}
|
||||
|
||||
/// A single contiguous edit within an `UpdateFile` hunk.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct UpdateFileChunk {
|
||||
/// A single line of context used to narrow down the position of the chunk
|
||||
@@ -93,9 +87,6 @@ pub struct UpdateFileChunk {
|
||||
pub is_end_of_file: bool,
|
||||
}
|
||||
|
||||
// ─── Public entry point ──────────────────────────────────────────────
|
||||
|
||||
/// Parse a patch string into a [`ParsedPatch`].
|
||||
pub fn parse_patch(patch: &str) -> Result<ParsedPatch, ParseError> {
|
||||
let mode = if PARSE_IN_STRICT_MODE {
|
||||
ParseMode::Strict
|
||||
@@ -105,10 +96,7 @@ pub fn parse_patch(patch: &str) -> Result<ParsedPatch, ParseError> {
|
||||
parse_patch_text(patch, mode)
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────
|
||||
|
||||
enum ParseMode {
|
||||
/// Parse the patch text argument as-is.
|
||||
Strict,
|
||||
/// In lenient mode we strip heredoc wrappers (`<<EOF` / `<<'EOF'` /
|
||||
/// `<<"EOF"`) before trying strict parsing.
|
||||
@@ -194,12 +182,10 @@ fn check_start_and_end_lines_strict(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single hunk from the start of `lines`.
|
||||
/// Returns the parsed hunk and the number of lines consumed.
|
||||
/// Returns the parsed hunk and the number of lines it consumed.
|
||||
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
|
||||
let first_line = lines[0].trim();
|
||||
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
|
||||
// ── Add File ─────────────────────────────────────────────
|
||||
let mut contents = String::new();
|
||||
let mut parsed_lines = 1;
|
||||
for add_line in &lines[1..] {
|
||||
@@ -219,7 +205,6 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
|
||||
parsed_lines,
|
||||
));
|
||||
} else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
|
||||
// ── Delete File ──────────────────────────────────────────
|
||||
return Ok((
|
||||
Hunk::DeleteFile {
|
||||
path: PathBuf::from(path),
|
||||
@@ -227,11 +212,9 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
|
||||
1,
|
||||
));
|
||||
} else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
|
||||
// ── Update File ──────────────────────────────────────────
|
||||
let mut remaining_lines = &lines[1..];
|
||||
let mut parsed_lines = 1;
|
||||
|
||||
// Optional: move-to line.
|
||||
let move_path = remaining_lines
|
||||
.first()
|
||||
.and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
|
||||
@@ -243,13 +226,12 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
while !remaining_lines.is_empty() {
|
||||
// Skip blank lines between chunks.
|
||||
if remaining_lines[0].trim().is_empty() {
|
||||
parsed_lines += 1;
|
||||
remaining_lines = &remaining_lines[1..];
|
||||
continue;
|
||||
}
|
||||
// Stop at the next hunk header.
|
||||
// Any `***` line belongs to the next hunk or the patch trailer.
|
||||
if remaining_lines[0].starts_with("***") {
|
||||
break;
|
||||
}
|
||||
@@ -302,7 +284,6 @@ fn parse_update_file_chunk(
|
||||
});
|
||||
}
|
||||
|
||||
// Check for explicit @@ context marker.
|
||||
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
|
||||
(None, 1)
|
||||
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
|
||||
@@ -348,8 +329,8 @@ fn parse_update_file_chunk(
|
||||
break;
|
||||
}
|
||||
line_contents => match line_contents.chars().next() {
|
||||
// An empty line carries no ' ' prefix but is still context.
|
||||
None => {
|
||||
// Interpret empty line as a context line.
|
||||
chunk.old_lines.push(String::new());
|
||||
chunk.new_lines.push(String::new());
|
||||
}
|
||||
@@ -385,8 +366,6 @@ fn parse_update_file_chunk(
|
||||
Ok((chunk, parsed_lines + start_index))
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -584,7 +563,6 @@ mod tests {
|
||||
let expected_error =
|
||||
InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
|
||||
|
||||
// <<EOF variant
|
||||
let patch_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_in_heredoc, ParseMode::Strict),
|
||||
@@ -597,7 +575,6 @@ mod tests {
|
||||
expected_hunks
|
||||
);
|
||||
|
||||
// <<'EOF' variant
|
||||
let patch_in_sq_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_in_sq_heredoc, ParseMode::Strict),
|
||||
@@ -610,7 +587,6 @@ mod tests {
|
||||
expected_hunks
|
||||
);
|
||||
|
||||
// <<"EOF" variant
|
||||
let patch_in_dq_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_in_dq_heredoc, ParseMode::Strict),
|
||||
@@ -623,7 +599,7 @@ mod tests {
|
||||
expected_hunks
|
||||
);
|
||||
|
||||
// Mismatched quotes → fail even in lenient mode
|
||||
// Mismatched heredoc quotes must fail even in lenient mode.
|
||||
let patch_in_mismatch = format!("<<\"EOF'\n{patch_text}\nEOF\n");
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_in_mismatch, ParseMode::Strict),
|
||||
@@ -634,7 +610,6 @@ mod tests {
|
||||
Err(expected_error)
|
||||
);
|
||||
|
||||
// Missing closing heredoc marker
|
||||
let patch_missing_close =
|
||||
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
|
||||
assert_eq!(
|
||||
|
||||
@@ -2,27 +2,15 @@
|
||||
//!
|
||||
//! Ported verbatim from `codex-rs/apply-patch/src/seek_sequence.rs`.
|
||||
//!
|
||||
//! Attempts to find a sequence of `pattern` lines within `lines` beginning at
|
||||
//! or after `start`. Matches are attempted with decreasing strictness:
|
||||
//!
|
||||
//! 1. **Exact match**
|
||||
//! 2. **rstrip** — ignore trailing whitespace
|
||||
//! 3. **trim** — ignore leading and trailing whitespace
|
||||
//! 4. **Unicode normalise** — normalise common Unicode punctuation to ASCII
|
||||
//! equivalents (typographic dashes → `-`, smart quotes → `'`/`"`, etc.)
|
||||
//! The whole input is rescanned once per pass, in decreasing order of
|
||||
//! strictness, so an exact match anywhere in the file always wins over a
|
||||
//! whitespace- or punctuation-insensitive match earlier in the file.
|
||||
|
||||
/// Find `pattern` within `lines` starting at `start`.
|
||||
/// Returns the index in `lines` where `pattern` starts, or `None`.
|
||||
///
|
||||
/// When `eof` is `true`, the search begins at the end of the file (so that
|
||||
/// patterns intended to match file endings are applied at the end), falling
|
||||
/// back to searching from `start` if needed.
|
||||
///
|
||||
/// Returns the starting index of the match, or `None` if not found.
|
||||
///
|
||||
/// # Edge cases
|
||||
///
|
||||
/// - Empty `pattern` → returns `Some(start)` (no-op match).
|
||||
/// - `pattern.len() > lines.len()` → returns `None`.
|
||||
/// When `eof` is `true` the search window begins at the last position where
|
||||
/// `pattern` could still fit, so patterns anchored to the end of a file match
|
||||
/// their final occurrence rather than the first.
|
||||
pub fn seek_sequence(
|
||||
lines: &[String],
|
||||
pattern: &[String],
|
||||
@@ -33,8 +21,6 @@ pub fn seek_sequence(
|
||||
return Some(start);
|
||||
}
|
||||
|
||||
// When the pattern is longer than the available input there is no
|
||||
// possible match.
|
||||
if pattern.len() > lines.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -45,14 +31,14 @@ pub fn seek_sequence(
|
||||
start
|
||||
};
|
||||
|
||||
// ── Pass 1: exact match ──────────────────────────────────────────
|
||||
// Pass 1: exact.
|
||||
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
||||
if lines[i..i + pattern.len()] == *pattern {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: rstrip match ─────────────────────────────────────────
|
||||
// Pass 2: ignoring trailing whitespace.
|
||||
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
||||
let mut ok = true;
|
||||
for (p_idx, pat) in pattern.iter().enumerate() {
|
||||
@@ -66,7 +52,7 @@ pub fn seek_sequence(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 3: trim both sides ──────────────────────────────────────
|
||||
// Pass 3: ignoring leading and trailing whitespace.
|
||||
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
|
||||
let mut ok = true;
|
||||
for (p_idx, pat) in pattern.iter().enumerate() {
|
||||
@@ -80,10 +66,9 @@ pub fn seek_sequence(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 4: Unicode normalise ────────────────────────────────────
|
||||
// Normalise common Unicode punctuation to ASCII equivalents so that
|
||||
// diffs authored with plain ASCII characters can still be applied to
|
||||
// source files that contain typographic dashes / quotes, etc.
|
||||
// Pass 4: normalising Unicode punctuation to ASCII, so that a diff
|
||||
// authored with plain ASCII still applies to a source file containing
|
||||
// typographic dashes, smart quotes, or exotic spaces.
|
||||
fn normalise(s: &str) -> String {
|
||||
s.trim()
|
||||
.chars()
|
||||
@@ -120,8 +105,6 @@ pub fn seek_sequence(
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::seek_sequence;
|
||||
@@ -177,9 +160,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_unicode_normalise_matches_typographic_dashes() {
|
||||
// Line contains EN DASH (\u{2013}).
|
||||
// \u{2013} is EN DASH.
|
||||
let lines = to_vec(&["hello \u{2013} world"]);
|
||||
// Pattern uses plain ASCII dash.
|
||||
let pattern = to_vec(&["hello - world"]);
|
||||
assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0));
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ use super::errors::ParseError;
|
||||
use super::parser::{self, Hunk};
|
||||
use super::{apply, errors::ApplyPatchError};
|
||||
|
||||
// ─── Description ─────────────────────────────────────────────────────
|
||||
|
||||
/// Tool description derived from the codex `apply_patch_tool_instructions.md`.
|
||||
const DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
|
||||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
@@ -92,8 +90,6 @@ It is important to remember:
|
||||
- File references can only be relative, NEVER ABSOLUTE.
|
||||
"#;
|
||||
|
||||
// ─── Input ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Input for the `apply_patch` tool.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct ApplyPatchInput {
|
||||
@@ -101,15 +97,10 @@ pub struct ApplyPatchInput {
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
// ─── Tool ────────────────────────────────────────────────────────────
|
||||
|
||||
/// ApplyPatch tool — applies multi-file patches in the codex patch format.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ApplyPatchTool;
|
||||
|
||||
// ─── Internal types ──────────────────────────────────────────────────
|
||||
|
||||
/// A computed file change — all content determined in-memory, ready to write.
|
||||
/// A change whose content is fully resolved in memory, ready to write.
|
||||
enum FileChange {
|
||||
Add {
|
||||
path: PathBuf,
|
||||
@@ -132,9 +123,6 @@ enum FileChange {
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Create parent directories for a file path if they don't exist.
|
||||
async fn ensure_parent_dirs(path: &std::path::Path) -> Result<(), kigi_tool_runtime::ToolError> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
@@ -149,8 +137,8 @@ async fn ensure_parent_dirs(path: &std::path::Path) -> Result<(), kigi_tool_runt
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute all file changes in memory without writing anything.
|
||||
/// Returns an error string if any hunk can't be applied.
|
||||
/// Resolves every hunk against the filesystem without writing, so a patch that
|
||||
/// fails partway through leaves nothing on disk.
|
||||
async fn compute_all_changes(
|
||||
cwd: &std::path::Path,
|
||||
fs: &Arc<dyn AsyncFileSystem>,
|
||||
@@ -215,7 +203,6 @@ async fn compute_all_changes(
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Read a file via AsyncFileSystem and convert to String.
|
||||
async fn read_file_as_string(
|
||||
fs: &Arc<dyn AsyncFileSystem>,
|
||||
path: &std::path::Path,
|
||||
@@ -224,7 +211,6 @@ async fn read_file_as_string(
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
/// Build the codex-style summary string.
|
||||
fn build_summary(results: &[ApplyPatchFileResult]) -> String {
|
||||
let mut out = String::from("Success. Updated the following files:\n");
|
||||
for r in results {
|
||||
@@ -232,15 +218,14 @@ fn build_summary(results: &[ApplyPatchFileResult]) -> String {
|
||||
"added" => "A",
|
||||
"deleted" => "D",
|
||||
"moved" => "M",
|
||||
_ => "M", // "modified"
|
||||
// "modified"
|
||||
_ => "M",
|
||||
};
|
||||
let _ = writeln!(out, "{prefix} {}", r.path.display());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for ApplyPatchTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Edit
|
||||
@@ -307,7 +292,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
}
|
||||
let tool_call_id = ctx.call_id.as_str().to_owned();
|
||||
|
||||
// ── Phase 1: Parse ───────────────────────────────────────
|
||||
let parsed = match parser::parse_patch(&input.patch) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -328,19 +312,16 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Phase 2: Compute all changes in memory (no writes yet) ───
|
||||
let changes = match compute_all_changes(&cwd, &fs, &parsed.hunks).await {
|
||||
Ok(c) => c,
|
||||
Err(msg) => return Ok(ApplyPatchOutput::ApplicationError(msg)),
|
||||
};
|
||||
|
||||
// ── Phase 3: Apply all changes (write to filesystem) ─────
|
||||
let mut file_results = Vec::new();
|
||||
|
||||
for change in &changes {
|
||||
match change {
|
||||
FileChange::Add { path, content } => {
|
||||
// Create parent directories if needed.
|
||||
ensure_parent_dirs(path).await?;
|
||||
fs.write_file(path, content.as_bytes()).await.map_err(|e| {
|
||||
kigi_tool_runtime::ToolError::execution(
|
||||
@@ -428,7 +409,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
original_content,
|
||||
new_content,
|
||||
} => {
|
||||
// Create parent dirs for destination.
|
||||
ensure_parent_dirs(dest_path).await?;
|
||||
fs.write_file(dest_path, new_content.as_bytes())
|
||||
.await
|
||||
@@ -445,7 +425,8 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Notify destination (new file at new location).
|
||||
// A move surfaces as two notifications: a creation at the
|
||||
// destination and a deletion at the source.
|
||||
notification_handle.send_file_written(FileWritten {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
absolute_path: dest_path.clone(),
|
||||
@@ -453,7 +434,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
previous_content: None,
|
||||
is_new_file: true,
|
||||
});
|
||||
// Notify source (deleted).
|
||||
notification_handle.send_file_written(FileWritten {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
absolute_path: source_path.clone(),
|
||||
@@ -473,7 +453,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: Build summary ───────────────────────────────
|
||||
let tool_output_for_prompt = build_summary(&file_results);
|
||||
|
||||
Ok(ApplyPatchOutput::Success {
|
||||
@@ -492,7 +471,6 @@ mod tests {
|
||||
use crate::types::tool_metadata::test_ctx;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Set up Resources with real filesystem for tests.
|
||||
fn test_resources(cwd: &std::path::Path) -> Resources {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(cwd.to_path_buf()));
|
||||
@@ -501,7 +479,6 @@ mod tests {
|
||||
resources
|
||||
}
|
||||
|
||||
/// Build a runtime `ToolCallContext` with the given shared resources.
|
||||
fn make_input(patch: &str) -> ApplyPatchInput {
|
||||
ApplyPatchInput {
|
||||
patch: patch.to_string(),
|
||||
@@ -512,8 +489,6 @@ mod tests {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
// ── Add file ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_file_creates_with_correct_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -542,8 +517,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete file ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_file_removes_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -573,8 +546,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update file ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_file_modifies_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -605,8 +576,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Move file ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_file_renames_and_modifies() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -635,8 +604,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multiple files in one patch ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_files_in_one_patch() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -675,8 +642,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse error ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_error_returns_no_changes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -700,8 +665,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Application error ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn application_error_on_missing_lines() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -725,8 +688,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty patch ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_patch_returns_empty_patch_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! `CodexGrepFilesTool` — file-path-only regex search via ripgrep.
|
||||
//!
|
||||
//! This is a faithful port of `codex-rs/core/src/tools/handlers/grep_files.rs`.
|
||||
//! It returns **file paths only** (`--files-with-matches`), sorted by
|
||||
//! modification time. See the plan document for the full diff vs the
|
||||
//! kigi `GrepTool`.
|
||||
//! Faithful port of `codex-rs/core/src/tools/handlers/grep_files.rs`: returns
|
||||
//! file paths only (`--files-with-matches`), sorted by modification time.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -19,19 +17,13 @@ use crate::types::requirements::Expr;
|
||||
use crate::types::resources::Cwd;
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_LIMIT: usize = 100;
|
||||
const MAX_LIMIT: usize = 2000;
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// ─── Description ────────────────────────────────────────────────────
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Finds files whose contents match the pattern and lists them by modification time.";
|
||||
|
||||
// ─── Input ──────────────────────────────────────────────────────────
|
||||
|
||||
fn default_limit() -> usize {
|
||||
DEFAULT_LIMIT
|
||||
}
|
||||
@@ -55,10 +47,6 @@ pub struct CodexGrepFilesInput {
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
// ─── Tool ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Codex-namespace grep_files tool — file-path-only regex search.
|
||||
///
|
||||
/// Shares `ToolKind::Search` with the kigi `GrepTool`. These tools are
|
||||
/// namespace-exclusive — consumers enable either `Kigi` or `Codex` search,
|
||||
/// never both simultaneously. This follows the same pattern as
|
||||
@@ -67,10 +55,6 @@ pub struct CodexGrepFilesInput {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CodexGrepFilesTool;
|
||||
|
||||
// ─── rg execution ───────────────────────────────────────────────────
|
||||
|
||||
/// Run `rg --files-with-matches` and return matching file paths.
|
||||
///
|
||||
/// Direct port from `codex-rs/core/src/tools/handlers/grep_files.rs`.
|
||||
async fn run_rg_search(
|
||||
pattern: &str,
|
||||
@@ -114,8 +98,6 @@ async fn run_rg_search(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse newline-separated file paths from rg stdout.
|
||||
///
|
||||
/// Direct port from `codex-rs/core/src/tools/handlers/grep_files.rs`.
|
||||
fn parse_results(stdout: &[u8], limit: usize) -> Vec<String> {
|
||||
let mut results = Vec::new();
|
||||
@@ -136,8 +118,6 @@ fn parse_results(stdout: &[u8], limit: usize) -> Vec<String> {
|
||||
results
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for CodexGrepFilesTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Search
|
||||
@@ -193,7 +173,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
|
||||
|
||||
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
|
||||
|
||||
// Validation (exact codex rules)
|
||||
let pattern = input.pattern.trim().to_string();
|
||||
if pattern.is_empty() {
|
||||
return Ok(CodexGrepFilesOutput::Error(
|
||||
@@ -208,7 +187,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
|
||||
|
||||
let limit = input.limit.min(MAX_LIMIT);
|
||||
|
||||
// Resolve search path
|
||||
let search_path = match &input.path {
|
||||
Some(p) if !p.is_empty() => {
|
||||
let p = PathBuf::from(p);
|
||||
@@ -217,7 +195,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
|
||||
_ => cwd.clone(),
|
||||
};
|
||||
|
||||
// Verify path exists
|
||||
if let Err(err) = tokio::fs::metadata(&search_path).await {
|
||||
return Ok(CodexGrepFilesOutput::Error(format!(
|
||||
"unable to access `{}`: {err}",
|
||||
@@ -225,7 +202,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
|
||||
)));
|
||||
}
|
||||
|
||||
// Clean up include glob
|
||||
let include = input.include.as_deref().map(str::trim).and_then(|v| {
|
||||
if v.is_empty() {
|
||||
None
|
||||
@@ -234,7 +210,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
|
||||
}
|
||||
});
|
||||
|
||||
// Run rg
|
||||
let results = run_rg_search(&pattern, include.as_deref(), &search_path, limit, &cwd).await;
|
||||
|
||||
match results {
|
||||
@@ -260,7 +235,6 @@ mod tests {
|
||||
use std::process::Command as StdCommand;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Build a runtime `ToolCallContext` with the given resources.
|
||||
fn test_ctx(cwd: &Path) -> kigi_tool_runtime::ToolCallContext {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(cwd.to_path_buf()));
|
||||
@@ -276,9 +250,6 @@ mod tests {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a runtime `ToolCallContext` with the given resources.
|
||||
// ── Unit tests (parse_results) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parses_basic_results() {
|
||||
let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n";
|
||||
@@ -316,8 +287,6 @@ mod tests {
|
||||
assert!(parsed.is_empty());
|
||||
}
|
||||
|
||||
// ── Integration tests (run_rg_search) ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_search_returns_results() {
|
||||
if !rg_available() {
|
||||
@@ -380,8 +349,6 @@ mod tests {
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// ── Tool-level tests ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_reports_empty_pattern_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -532,7 +499,8 @@ mod tests {
|
||||
pattern: "needle".to_string(),
|
||||
include: None,
|
||||
path: None,
|
||||
limit: 5000, // exceeds MAX_LIMIT (2000)
|
||||
// exceeds MAX_LIMIT (2000)
|
||||
limit: 5000,
|
||||
};
|
||||
|
||||
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(tmp.path()), input)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! `CodexListDirTool` — paginated, depth-limited, BFS directory listing.
|
||||
//!
|
||||
//! This is a faithful port of `codex-rs/core/src/tools/handlers/list_dir.rs`.
|
||||
//! It does NOT respect `.gitignore`, does NOT exclude hidden files, and requires
|
||||
//! absolute paths. See the plan document for the full diff vs the kigi
|
||||
//! `ListDirTool`.
|
||||
//! Ported from `codex-rs/core/src/tools/handlers/list_dir.rs`. Unlike the kigi
|
||||
//! `ListDirTool`, it does not respect `.gitignore`, does not exclude hidden
|
||||
//! files, and requires absolute paths.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,21 +11,13 @@ use crate::types::output::{ListDirContent, ListDirOutput};
|
||||
use crate::types::requirements::Expr;
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum length (in bytes) for a single entry name before truncation.
|
||||
const MAX_ENTRY_LENGTH: usize = 500;
|
||||
|
||||
/// Number of spaces per depth level for indentation.
|
||||
const INDENTATION_SPACES: usize = 2;
|
||||
|
||||
// ─── Description ────────────────────────────────────────────────────
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels.";
|
||||
|
||||
// ─── Input ──────────────────────────────────────────────────────────
|
||||
|
||||
fn default_offset() -> usize {
|
||||
1
|
||||
}
|
||||
@@ -56,17 +47,14 @@ pub struct CodexListDirInput {
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
// ─── Internal types ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DirEntry {
|
||||
/// Full relative path from the listing root (used for sorting).
|
||||
/// Full relative path from the listing root; the sort key.
|
||||
name: String,
|
||||
/// Just the file/directory component name (used for display).
|
||||
/// Only the final path component.
|
||||
display_name: String,
|
||||
/// Depth level (0 = root's direct children).
|
||||
/// 0 for the root's direct children.
|
||||
depth: usize,
|
||||
/// Entry type.
|
||||
kind: DirEntryKind,
|
||||
}
|
||||
|
||||
@@ -80,10 +68,9 @@ enum DirEntryKind {
|
||||
|
||||
impl From<&std::fs::FileType> for DirEntryKind {
|
||||
fn from(ft: &std::fs::FileType) -> Self {
|
||||
// Check is_symlink() FIRST — on Unix, a symlink to a directory has
|
||||
// both is_symlink() and is_dir() true when the file_type is obtained
|
||||
// via tokio::fs::DirEntry::file_type() (which follows symlinks).
|
||||
// Codex checks symlink first so these are rendered with `@`, not `/`.
|
||||
// Symlink must be tested first: on Unix a symlink to a directory
|
||||
// answers true to both `is_symlink()` and `is_dir()`, and codex renders
|
||||
// such an entry with `@` rather than `/`.
|
||||
if ft.is_symlink() {
|
||||
DirEntryKind::Symlink
|
||||
} else if ft.is_dir() {
|
||||
@@ -96,15 +83,10 @@ impl From<&std::fs::FileType> for DirEntryKind {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Codex-namespace list_dir tool — paginated, depth-limited directory listing.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CodexListDirTool;
|
||||
|
||||
// ─── Core BFS logic ─────────────────────────────────────────────────
|
||||
|
||||
/// Orchestrator: collect entries via BFS → sort → paginate → format.
|
||||
async fn list_dir_slice(
|
||||
dir_path: &Path,
|
||||
offset: usize,
|
||||
@@ -116,30 +98,28 @@ async fn list_dir_slice(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read directory: {e}"))?;
|
||||
|
||||
// Sort by full relative path (slash-normalized), case-sensitive.
|
||||
// Slash-normalized and case-sensitive, matching codex.
|
||||
entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
// Empty directory is a valid success case, not an error.
|
||||
// An empty directory is a success case, not an error.
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let total = entries.len();
|
||||
|
||||
// offset is 1-indexed
|
||||
// offset is 1-indexed.
|
||||
let start_index = offset - 1;
|
||||
if start_index >= total {
|
||||
return Err("offset exceeds directory entry count".to_string());
|
||||
}
|
||||
|
||||
// Compute end index, saturating to avoid overflow with large limits.
|
||||
let end_index = start_index.saturating_add(limit).min(total);
|
||||
let page = &entries[start_index..end_index];
|
||||
|
||||
let mut lines: Vec<String> = page.iter().map(format_entry_line).collect();
|
||||
|
||||
// Overflow message when more entries exist beyond the page.
|
||||
// Use capped_limit (actual number of entries returned) to match codex behavior.
|
||||
// Codex reports the page size actually returned, not the requested limit.
|
||||
if end_index < total {
|
||||
let capped_limit = end_index - start_index;
|
||||
lines.push(format!("More than {} entries found", capped_limit));
|
||||
@@ -148,15 +128,12 @@ async fn list_dir_slice(
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// BFS walker using `tokio::fs::read_dir`.
|
||||
///
|
||||
/// Collects entries breadth-first up to `max_depth` levels. Directories
|
||||
/// beyond the depth limit are listed but not descended into.
|
||||
///
|
||||
/// Uses `PathBuf` for the relative prefix (matching codex's `prefix: &Path`
|
||||
/// + `prefix.join(&file_name)`). The raw `PathBuf` is kept for recursion so
|
||||
/// that subdirectory prefixes are never affected by `format_entry_name`
|
||||
/// truncation or normalization.
|
||||
/// The prefix carried through the queue is the raw joined path, never the
|
||||
/// formatted name, so that `format_entry_name` truncation and separator
|
||||
/// normalization cannot leak into deeper prefixes.
|
||||
async fn collect_entries(
|
||||
dir_path: &Path,
|
||||
relative_prefix: &Path,
|
||||
@@ -164,7 +141,7 @@ async fn collect_entries(
|
||||
max_depth: usize,
|
||||
entries: &mut Vec<DirEntry>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
// Queue items: (absolute path, raw relative prefix, depth)
|
||||
// (absolute path, raw relative prefix, depth)
|
||||
let mut queue: VecDeque<(PathBuf, PathBuf, usize)> = VecDeque::new();
|
||||
queue.push_back((
|
||||
dir_path.to_path_buf(),
|
||||
@@ -175,8 +152,7 @@ async fn collect_entries(
|
||||
while let Some((abs_path, rel_prefix, depth)) = queue.pop_front() {
|
||||
let mut read_dir = tokio::fs::read_dir(&abs_path).await?;
|
||||
|
||||
// Collect children first so we can sort them.
|
||||
// Each item: (DirEntry, absolute path, raw relative path for recursion)
|
||||
// (entry, absolute path, raw relative path for the next level)
|
||||
let mut children: Vec<(DirEntry, PathBuf, PathBuf)> = Vec::new();
|
||||
while let Some(entry) = read_dir.next_entry().await? {
|
||||
let file_type = entry.file_type().await?;
|
||||
@@ -185,9 +161,8 @@ async fn collect_entries(
|
||||
let raw_name = entry.file_name();
|
||||
let display_name = format_entry_component(&raw_name);
|
||||
|
||||
// Build the raw relative path using Path::join (matches codex).
|
||||
let entry_relative_path = rel_prefix.join(&raw_name);
|
||||
// The sort key is the formatted (slash-normalized, truncated) name.
|
||||
// Sorting is done on the formatted name, not the raw path.
|
||||
let name = format_entry_name(&entry_relative_path.to_string_lossy());
|
||||
|
||||
children.push((
|
||||
@@ -202,16 +177,13 @@ async fn collect_entries(
|
||||
));
|
||||
}
|
||||
|
||||
// Sort children by relative path for deterministic ordering.
|
||||
// Sort children so the traversal order is deterministic.
|
||||
children.sort_unstable_by(|a, b| a.0.name.cmp(&b.0.name));
|
||||
|
||||
for (dir_entry, child_abs_path, child_raw_rel) in children {
|
||||
let is_dir = dir_entry.kind == DirEntryKind::Directory;
|
||||
entries.push(dir_entry);
|
||||
|
||||
// Descend into directories if we haven't reached max depth.
|
||||
// Pass the raw relative path (not the formatted name) as the
|
||||
// prefix for the next level, matching codex behavior.
|
||||
if is_dir && depth + 1 < max_depth {
|
||||
queue.push_back((child_abs_path, child_raw_rel, depth + 1));
|
||||
}
|
||||
@@ -221,7 +193,6 @@ async fn collect_entries(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format a single entry line: `"{indent}{display_name}{suffix}"`.
|
||||
fn format_entry_line(entry: &DirEntry) -> String {
|
||||
let indent = " ".repeat(entry.depth * INDENTATION_SPACES);
|
||||
let suffix = match entry.kind {
|
||||
@@ -233,26 +204,23 @@ fn format_entry_line(entry: &DirEntry) -> String {
|
||||
format!("{}{}{}", indent, entry.display_name, suffix)
|
||||
}
|
||||
|
||||
/// Normalize path separators (backslash → forward slash) and truncate.
|
||||
/// Normalizes backslashes to forward slashes, then truncates.
|
||||
fn format_entry_name(path: &str) -> String {
|
||||
let normalized = path.replace('\\', "/");
|
||||
take_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
|
||||
}
|
||||
|
||||
/// Truncate an `OsStr` file name component at `MAX_ENTRY_LENGTH` bytes.
|
||||
fn format_entry_component(name: &std::ffi::OsStr) -> String {
|
||||
let s = name.to_string_lossy();
|
||||
take_at_char_boundary(&s, MAX_ENTRY_LENGTH).to_string()
|
||||
}
|
||||
|
||||
/// Truncate a string at a char boundary, returning at most `max_bytes` bytes.
|
||||
/// Yields at most `max_bytes` bytes, cut back to a char boundary.
|
||||
fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
|
||||
let end = crate::util::floor_char_boundary(s, max_bytes);
|
||||
&s[..end]
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for CodexListDirTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::ListDir
|
||||
@@ -358,12 +326,9 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Unit tests (core logic) ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_directory_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Create files and dirs.
|
||||
std::fs::write(tmp.path().join("file.txt"), "content").unwrap();
|
||||
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
|
||||
#[cfg(unix)]
|
||||
@@ -371,7 +336,6 @@ mod tests {
|
||||
|
||||
let result = list_dir_slice(tmp.path(), 1, 25, 2).await.unwrap();
|
||||
|
||||
// Should contain file.txt, subdir/, and on unix: link@
|
||||
let joined = result.join("\n");
|
||||
assert!(joined.contains("file.txt"), "missing file.txt in: {joined}");
|
||||
assert!(joined.contains("subdir/"), "missing subdir/ in: {joined}");
|
||||
@@ -396,7 +360,6 @@ mod tests {
|
||||
std::fs::create_dir_all(&subsub).unwrap();
|
||||
std::fs::write(subsub.join("deep.txt"), "").unwrap();
|
||||
|
||||
// depth=1: only top-level entries (the dir "a" but not its children)
|
||||
let depth1 = list_dir_slice(tmp.path(), 1, 100, 1).await.unwrap();
|
||||
let joined1 = depth1.join("\n");
|
||||
assert!(joined1.contains("a/"), "should see dir a/");
|
||||
@@ -406,7 +369,6 @@ mod tests {
|
||||
"should NOT see deep.txt at depth 1"
|
||||
);
|
||||
|
||||
// depth=2: top-level + children of a
|
||||
let depth2 = list_dir_slice(tmp.path(), 1, 100, 2).await.unwrap();
|
||||
let joined2 = depth2.join("\n");
|
||||
assert!(joined2.contains("a/"), "should see dir a/");
|
||||
@@ -416,7 +378,6 @@ mod tests {
|
||||
"should NOT see deep.txt at depth 2"
|
||||
);
|
||||
|
||||
// depth=3: everything
|
||||
let depth3 = list_dir_slice(tmp.path(), 1, 100, 3).await.unwrap();
|
||||
let joined3 = depth3.join("\n");
|
||||
assert!(joined3.contains("a/"), "should see dir a/");
|
||||
@@ -434,7 +395,6 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("a.txt"), "").unwrap();
|
||||
std::fs::write(tmp.path().join("b.txt"), "").unwrap();
|
||||
|
||||
// First page: limit=2, should get a.txt, b.txt + overflow message
|
||||
let page1 = list_dir_slice(tmp.path(), 1, 2, 1).await.unwrap();
|
||||
assert!(page1[0].contains("a.txt"), "first entry should be a.txt");
|
||||
assert!(page1[1].contains("b.txt"), "second entry should be b.txt");
|
||||
@@ -443,7 +403,6 @@ mod tests {
|
||||
"should have overflow message"
|
||||
);
|
||||
|
||||
// Second page: offset=3 → c.txt only, no overflow
|
||||
let page2 = list_dir_slice(tmp.path(), 3, 2, 1).await.unwrap();
|
||||
assert_eq!(page2.len(), 1, "second page should have 1 entry");
|
||||
assert!(page2[0].contains("c.txt"), "should be c.txt");
|
||||
@@ -454,7 +413,6 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("only.txt"), "").unwrap();
|
||||
|
||||
// usize::MAX as limit should not panic
|
||||
let result = list_dir_slice(tmp.path(), 1, usize::MAX, 1).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].contains("only.txt"));
|
||||
@@ -463,7 +421,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn indicates_truncated_results() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Create 40 files
|
||||
for i in 0..40 {
|
||||
std::fs::write(tmp.path().join(format!("file_{:03}.txt", i)), "").unwrap();
|
||||
}
|
||||
@@ -485,13 +442,10 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("a.txt"), "").unwrap();
|
||||
std::fs::write(tmp.path().join("m.txt"), "").unwrap();
|
||||
|
||||
// limit=2, offset=2 → should get m.txt (2nd sorted entry)
|
||||
let result = list_dir_slice(tmp.path(), 2, 1, 1).await.unwrap();
|
||||
assert!(result[0].contains("m.txt"), "offset=2 should land on m.txt");
|
||||
}
|
||||
|
||||
// ── Tool integration tests ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_lists_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -654,7 +608,6 @@ mod tests {
|
||||
let result = list_dir_slice(tmp.path(), 1, 25, 2).await.unwrap();
|
||||
assert!(result.is_empty(), "empty dir should return empty vec");
|
||||
|
||||
// Also verify the tool-level wrapper returns Content, not Error.
|
||||
let tool = CodexListDirTool;
|
||||
let ctx = kigi_tool_runtime::ToolCallContext::default();
|
||||
let input = CodexListDirInput {
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
//! Indentation-mode reader — exact port of codex `indentation::*`.
|
||||
//!
|
||||
//! Loads all file lines, computes effective indents (blank lines inherit
|
||||
//! from previous non-blank), then expands bidirectionally from an anchor
|
||||
//! line using the codex interleaved single-loop algorithm with inline
|
||||
//! sibling filtering and inline header-comment handling.
|
||||
//! from the previous non-blank line), then expands bidirectionally from an
|
||||
//! anchor line. Sibling filtering and header-comment inclusion happen inline
|
||||
//! during that expansion rather than as separate passes.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::text_utils::format_display;
|
||||
|
||||
/// Tab width used for indent measurement (spaces per tab).
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
/// Comment prefixes recognized for `include_header`.
|
||||
/// Prefixes that make a line eligible for `include_header`.
|
||||
const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
|
||||
|
||||
/// Configuration for indentation-mode reading.
|
||||
///
|
||||
/// Mirrors codex `IndentationModeOptions`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IndentationOptions {
|
||||
@@ -27,47 +24,35 @@ pub(crate) struct IndentationOptions {
|
||||
pub max_lines: Option<usize>,
|
||||
}
|
||||
|
||||
// ─── LineRecord ──────────────────────────────────────────────────────
|
||||
|
||||
/// Per-line record: number, raw (untruncated), display (truncated), indent.
|
||||
///
|
||||
/// Matches codex `LineRecord { number, raw, display, indent }`.
|
||||
/// `raw` is used for `trimmed()` / `is_blank()` / `is_comment()`;
|
||||
/// `display` is used for output formatting.
|
||||
/// Matches codex `LineRecord`. Classification (`trimmed`, `is_blank`,
|
||||
/// `is_comment`) reads `raw`; only output formatting reads `display`.
|
||||
#[derive(Debug)]
|
||||
struct LineRecord {
|
||||
/// 1-indexed line number.
|
||||
number: usize,
|
||||
/// Raw untruncated line content (UTF-8 lossy).
|
||||
/// Untruncated line content (UTF-8 lossy).
|
||||
raw: String,
|
||||
/// Display string (UTF-8 lossy, truncated at MAX_LINE_LENGTH).
|
||||
/// Line content truncated at MAX_LINE_LENGTH.
|
||||
display: String,
|
||||
/// Raw indent level (number of leading spaces, tabs counted as TAB_WIDTH).
|
||||
/// Leading spaces, counting each tab as TAB_WIDTH.
|
||||
indent: usize,
|
||||
}
|
||||
|
||||
impl LineRecord {
|
||||
/// Leading-whitespace-stripped raw content. Codex uses `raw.trim_start()`.
|
||||
fn trimmed(&self) -> &str {
|
||||
self.raw.trim_start()
|
||||
}
|
||||
|
||||
/// Whether the line is blank (only whitespace).
|
||||
fn is_blank(&self) -> bool {
|
||||
self.trimmed().is_empty()
|
||||
}
|
||||
|
||||
/// Whether the line is a comment (starts with a known prefix).
|
||||
/// Codex uses `self.raw.trim().starts_with(prefix)`.
|
||||
fn is_comment(&self) -> bool {
|
||||
let t = self.raw.trim();
|
||||
COMMENT_PREFIXES.iter().any(|p| t.starts_with(p))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core functions ──────────────────────────────────────────────────
|
||||
|
||||
/// Collect line records from raw file bytes.
|
||||
fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
|
||||
if bytes.is_empty() {
|
||||
return vec![];
|
||||
@@ -98,7 +83,7 @@ fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining content after last \n (or file without trailing \n).
|
||||
// Trailing content after the last \n, i.e. a file with no final newline.
|
||||
if start < bytes.len() {
|
||||
line_num += 1;
|
||||
let mut end = bytes.len();
|
||||
@@ -120,7 +105,6 @@ fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
|
||||
records
|
||||
}
|
||||
|
||||
/// Measure indent: count leading spaces (tabs = TAB_WIDTH spaces).
|
||||
fn measure_indent(line: &str) -> usize {
|
||||
let mut indent = 0;
|
||||
for ch in line.chars() {
|
||||
@@ -133,8 +117,8 @@ fn measure_indent(line: &str) -> usize {
|
||||
indent
|
||||
}
|
||||
|
||||
/// Compute effective indents: blank lines inherit the indent of the
|
||||
/// previous non-blank line. Returns a vec parallel to `records`.
|
||||
/// Blank lines inherit the indent of the previous non-blank line. The result
|
||||
/// is parallel to `records`.
|
||||
fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
|
||||
let mut effective = Vec::with_capacity(records.len());
|
||||
let mut last_non_blank_indent = 0usize;
|
||||
@@ -151,14 +135,12 @@ fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
|
||||
effective
|
||||
}
|
||||
|
||||
/// Read a block of lines using indentation-based expansion from an anchor.
|
||||
/// Read a block of lines by expanding outward from an anchor line, following
|
||||
/// the indentation structure around it.
|
||||
///
|
||||
/// This is the main entry point for indentation mode.
|
||||
///
|
||||
/// Ported from codex `indentation::read_block` — uses the codex interleaved
|
||||
/// single-loop algorithm with two cursors (i going up, j going down) that
|
||||
/// alternate. Sibling filtering and header-comment inclusion are handled
|
||||
/// **inline** during expansion, not as post-processing passes.
|
||||
/// Ported from codex `indentation::read_block`: one loop drives two cursors
|
||||
/// (`i` upward, `j` downward) that alternate, with sibling filtering and
|
||||
/// header-comment inclusion applied inline rather than as later passes.
|
||||
pub(crate) fn read_block(
|
||||
bytes: &[u8],
|
||||
offset: usize,
|
||||
@@ -179,57 +161,48 @@ pub(crate) fn read_block(
|
||||
|
||||
let effective = compute_effective_indents(&collected);
|
||||
|
||||
// guard_limit = max_lines.unwrap_or(limit). Codex validates this > 0.
|
||||
let guard_limit = options.max_lines.unwrap_or(limit);
|
||||
if guard_limit == 0 {
|
||||
return Err("max_lines must be greater than zero".to_string());
|
||||
}
|
||||
|
||||
// final_limit = min(limit, guard_limit, collected.len())
|
||||
let final_limit = limit.min(guard_limit).min(collected.len());
|
||||
|
||||
let anchor_idx = anchor - 1; // 0-indexed
|
||||
// anchor is 1-indexed.
|
||||
let anchor_idx = anchor - 1;
|
||||
let anchor_indent = effective[anchor_idx];
|
||||
|
||||
// Compute min_indent threshold.
|
||||
let min_indent = if options.max_levels == 0 {
|
||||
0
|
||||
} else {
|
||||
anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
|
||||
};
|
||||
|
||||
// Early return: final_limit == 1 → just the anchor line.
|
||||
if final_limit == 1 {
|
||||
let rec = &collected[anchor_idx];
|
||||
return Ok(vec![format!("L{}: {}", rec.number, rec.display)]);
|
||||
}
|
||||
|
||||
// ── Interleaved bidirectional expansion ──────────────────────
|
||||
//
|
||||
// Codex algorithm (lines 293–357): single `while out.len() < final_limit`
|
||||
// loop. BOTH cursors are tried every iteration (up first, then down).
|
||||
// A `progressed` counter tracks whether either direction added a line;
|
||||
// if 0, both are exhausted and we break.
|
||||
//
|
||||
// `i` starts at anchor_idx - 1 going down to 0 (or -1 = exhausted).
|
||||
// `j` starts at anchor_idx + 1 going up to collected.len() (= exhausted).
|
||||
// Interleaved bidirectional expansion, per codex lines 293-357: both
|
||||
// cursors are tried on every iteration, up first, and the loop ends once
|
||||
// neither direction contributed a line.
|
||||
|
||||
let mut out: VecDeque<usize> = VecDeque::new();
|
||||
out.push_back(anchor_idx);
|
||||
|
||||
// Use isize for i so we can represent -1 as "exhausted"
|
||||
// `i` is signed so that -1 can mark the upward cursor exhausted; `j`
|
||||
// reaching `n` marks the downward one exhausted.
|
||||
let mut i: isize = anchor_idx as isize - 1;
|
||||
let mut j: usize = anchor_idx + 1;
|
||||
let n = collected.len();
|
||||
|
||||
// Counters: track boundary-level lines accepted in each direction.
|
||||
// Boundary-level lines accepted in each direction.
|
||||
let mut i_counter_min_indent: usize = 0;
|
||||
let mut j_counter_min_indent: usize = 0;
|
||||
|
||||
while out.len() < final_limit {
|
||||
let mut progressed = 0usize;
|
||||
|
||||
// ── ALWAYS try upward cursor (if available) ─────────────
|
||||
if i >= 0 {
|
||||
let added = expand_up(
|
||||
&collected,
|
||||
@@ -244,13 +217,12 @@ pub(crate) fn read_block(
|
||||
if added {
|
||||
progressed += 1;
|
||||
}
|
||||
// Short-cut: codex breaks after up if limit reached.
|
||||
// Codex bails out here without trying the downward cursor.
|
||||
if out.len() >= final_limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ALWAYS try downward cursor (if available) ───────────
|
||||
if j < n {
|
||||
let added = expand_down(
|
||||
&effective,
|
||||
@@ -271,10 +243,8 @@ pub(crate) fn read_block(
|
||||
}
|
||||
}
|
||||
|
||||
// Trim leading/trailing blank lines.
|
||||
trim_empty_lines(&collected, &mut out);
|
||||
|
||||
// Format output.
|
||||
let lines: Vec<String> = out
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
@@ -286,16 +256,11 @@ pub(crate) fn read_block(
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Expand the upward cursor by one step. Returns true if a line was
|
||||
/// added to `out` (net gain — not reverted).
|
||||
/// Advance the upward cursor one step, returning true only if the line
|
||||
/// survived — a line that is pushed and then reverted counts as no gain.
|
||||
///
|
||||
/// Codex logic (lines 296–320):
|
||||
/// 1. If `eff >= min_indent`: push_front (line 300).
|
||||
/// 2. If `eff == min_indent && !include_siblings`:
|
||||
/// - `can_take_line = allow_header_comment || counter == 0`
|
||||
/// - If can_take_line: increment counter (line is kept).
|
||||
/// - If !can_take_line: pop_front (revert THIS just-pushed line), stop cursor.
|
||||
/// 3. If `eff < min_indent`: stop cursor, return false.
|
||||
/// Codex (lines 296-320) pushes the candidate before deciding whether the
|
||||
/// sibling filter rejects it, so the revert pops the line just pushed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn expand_up(
|
||||
collected: &[LineRecord],
|
||||
@@ -315,41 +280,34 @@ fn expand_up(
|
||||
let eff = effective[iu];
|
||||
|
||||
if eff < min_indent {
|
||||
// Below threshold — stop cursor.
|
||||
*i = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
// eff >= min_indent — push first (codex line 300), then filter.
|
||||
// Push first (codex line 300), filter afterwards.
|
||||
out.push_front(iu);
|
||||
*i -= 1;
|
||||
|
||||
// Sibling filter: only applies when eff == min_indent && !include_siblings.
|
||||
if eff == min_indent && !include_siblings {
|
||||
let allow_header_comment = include_header && collected[iu].is_comment();
|
||||
let can_take_line = allow_header_comment || *counter == 0;
|
||||
if can_take_line {
|
||||
*counter += 1; // line is kept, increment counter
|
||||
*counter += 1;
|
||||
} else {
|
||||
// Revert THIS just-pushed line and stop cursor.
|
||||
out.pop_front();
|
||||
*i = -1;
|
||||
return false; // net: no line added
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Expand the downward cursor by one step. Returns true if a line was
|
||||
/// added to `out` (net gain — not reverted).
|
||||
/// Advance the downward cursor one step, returning true only if the line
|
||||
/// survived — a line that is pushed and then reverted counts as no gain.
|
||||
///
|
||||
/// Codex logic (lines 332–348):
|
||||
/// 1. If `eff >= min_indent`: push_back (line 334).
|
||||
/// 2. If `eff == min_indent && !include_siblings`:
|
||||
/// - If `counter > 0`: pop_back (revert THIS just-pushed line), stop cursor.
|
||||
/// - Always increment counter (line 346).
|
||||
/// 3. If `eff < min_indent`: stop cursor, return false.
|
||||
/// Codex (lines 332-348) pushes the candidate before deciding whether the
|
||||
/// sibling filter rejects it, so the revert pops the line just pushed.
|
||||
fn expand_down(
|
||||
effective: &[usize],
|
||||
out: &mut VecDeque<usize>,
|
||||
@@ -367,32 +325,29 @@ fn expand_down(
|
||||
let eff = effective[ju];
|
||||
|
||||
if eff < min_indent {
|
||||
// Below threshold — stop cursor.
|
||||
*j = n;
|
||||
return false;
|
||||
}
|
||||
|
||||
// eff >= min_indent — push first (codex line 334), then filter.
|
||||
// Push first (codex line 334), filter afterwards.
|
||||
out.push_back(ju);
|
||||
*j += 1;
|
||||
|
||||
// Sibling filter: only applies when eff == min_indent && !include_siblings.
|
||||
if eff == min_indent && !include_siblings {
|
||||
if *counter > 0 {
|
||||
// Second+ boundary-level line — revert THIS just-pushed line.
|
||||
// A second boundary-level line ends the downward walk, but codex
|
||||
// line 346 counts it anyway.
|
||||
out.pop_back();
|
||||
*j = n; // stop cursor
|
||||
// Still increment counter (codex line 346: always increments).
|
||||
*j = n;
|
||||
*counter += 1;
|
||||
return false; // net: no line added
|
||||
return false;
|
||||
}
|
||||
*counter += 1; // always increment (codex line 346)
|
||||
*counter += 1;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Trim leading and trailing blank lines from the result deque.
|
||||
fn trim_empty_lines(records: &[LineRecord], deque: &mut VecDeque<usize>) {
|
||||
while let Some(&idx) = deque.front() {
|
||||
if records[idx].is_blank() {
|
||||
@@ -410,8 +365,6 @@ fn trim_empty_lines(records: &[LineRecord], deque: &mut VecDeque<usize>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -432,27 +385,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exact-output tests ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn captures_function_block_with_limit() {
|
||||
// anchor=2 (x=1, indent 4), max_levels=1, min_indent = 4-4 = 0.
|
||||
// With min_indent=0, the entire file is reachable (no indent is below 0).
|
||||
// Sibling filter: going up, def foo is first boundary (counter=1, accepted).
|
||||
// Going down: y, return, blank (effective=4 > 0), def bar (effective=0 == min,
|
||||
// counter=1, accepted), pass (effective=4 > 0, accepted). No second boundary hit,
|
||||
// so downward includes everything.
|
||||
// anchor=2 (x = 1, indent 4), max_levels=1, so min_indent = 4-4 = 0 and
|
||||
// every line is reachable. Each direction meets only one boundary-level
|
||||
// line (def foo upward, def bar downward), so nothing is filtered out.
|
||||
let content =
|
||||
b"def foo():\n x = 1\n y = 2\n return x + y\n\ndef bar():\n pass\n";
|
||||
|
||||
// Without limit: entire file (minus blank trim)
|
||||
let opts_full = make_opts(Some(2), 1, false, true, None);
|
||||
let result_full = read_block(content, 1, 2000, opts_full).unwrap();
|
||||
assert!(result_full.iter().any(|l| l.contains("def foo():")));
|
||||
assert!(result_full.iter().any(|l| l.contains("x = 1")));
|
||||
assert!(result_full.iter().any(|l| l.contains("return x + y")));
|
||||
|
||||
// With max_lines=4: capped to 4 lines
|
||||
let opts_limited = make_opts(Some(2), 1, false, true, Some(4));
|
||||
let result = read_block(content, 1, 4, opts_limited).unwrap();
|
||||
assert_eq!(
|
||||
@@ -476,11 +422,8 @@ mod tests {
|
||||
// L6: (blank, effective=8)
|
||||
// L7: def other(self): (indent 4)
|
||||
// L8: pass (indent 8)
|
||||
// anchor=3, max_levels=2, min_indent = 8-8 = 0.
|
||||
// Both directions try every iteration. Up first: class MyClass (indent 0,
|
||||
// boundary counter=1, kept). Down: y (eff=8>0, kept). Up: exhausted (i=-1).
|
||||
// Down: return, blank, def other (boundary counter=1, kept since counter was 0),
|
||||
// pass. All accepted because min_indent=0.
|
||||
// anchor=3, max_levels=2, so min_indent = 8-8 = 0 and every line is
|
||||
// reachable; the one boundary line each direction meets is kept.
|
||||
let content = b"class MyClass:\n def method(self):\n x = 1\n y = 2\n return x + y\n\n def other(self):\n pass\n";
|
||||
let opts = make_opts(Some(3), 2, false, true, None);
|
||||
let result = read_block(content, 1, 2000, opts).unwrap();
|
||||
@@ -521,25 +464,10 @@ class C:
|
||||
def e(self):
|
||||
pass
|
||||
";
|
||||
// Without siblings: up hits def anchor (boundary, counter 0→1, kept),
|
||||
// then def b (boundary, counter==1, can_take_line=false → REVERT anchor, stop).
|
||||
// Wait: up goes from anchor_idx=6 upward. i starts at 5 (def anchor line).
|
||||
// L6 (idx 5) = " def anchor(self):" → eff=4 == min=4. counter==0, can_take=true.
|
||||
// Push. counter=1. i=4.
|
||||
// L5 (idx 4) = " pass" → eff=8 > 4. Push. i=3.
|
||||
// L4 (idx 3) = " def b(self):" → eff=4 == min=4. counter==1, can_take=false.
|
||||
// REVERT (pop front = L4 just pushed). Stop. i=-1.
|
||||
// Wait that's not right. Let me retrace...
|
||||
// Actually: push L4 first, THEN check. can_take_line = false (counter==1, not comment).
|
||||
// Revert = pop front = L4 (the just-pushed one). i=-1.
|
||||
//
|
||||
// Down: j starts at 7 (def d).
|
||||
// L8 (idx 7) = " def d(self):" → eff=4 == min=4. counter==0 → kept. counter=1.
|
||||
// L9 (idx 8) = " pass" → eff=8>4 → kept.
|
||||
// L10 (idx 9) = " def e(self):" → eff=4 == min=4. counter>0 → REVERT L10, stop.
|
||||
//
|
||||
// Result (before trim): [L5:pass, L6:def anchor, L7:x=1, L8:def d, L9:pass]
|
||||
// After blank trim (no blanks): same.
|
||||
// Without siblings each direction accepts exactly one boundary line:
|
||||
// upward L6 (def anchor), downward L8 (def d). The next boundary line
|
||||
// in each direction (L4, L10) is pushed, then reverted, ending that
|
||||
// cursor — so the block spans L5..L9.
|
||||
let opts_no_sibs = make_opts(Some(7), 1, false, true, None);
|
||||
let result_no_sibs = read_block(content, 1, 2000, opts_no_sibs).unwrap();
|
||||
|
||||
@@ -554,7 +482,7 @@ class C:
|
||||
]
|
||||
);
|
||||
|
||||
// With siblings: all methods at indent 4 should be included
|
||||
// With siblings, every method at indent 4 survives the filter.
|
||||
let opts_sibs = make_opts(Some(7), 1, true, true, None);
|
||||
let result_sibs = read_block(content, 1, 2000, opts_sibs).unwrap();
|
||||
|
||||
@@ -571,8 +499,8 @@ class C:
|
||||
// L3: def compute(x): (indent 0)
|
||||
// L4: return x * 2 (indent 4) ← ANCHOR
|
||||
//
|
||||
// anchor=4, max_levels=1, min_indent = 4-4 = 0.
|
||||
// With include_header=true: comments at indent 0 pass via allow_header_comment.
|
||||
// anchor=4, max_levels=1, min_indent = 4-4 = 0. The comments sit at the
|
||||
// boundary indent, so only include_header lets them through.
|
||||
let content = b"# Helper function\n# for computation\ndef compute(x):\n return x * 2\n";
|
||||
|
||||
let opts_header = make_opts(Some(4), 1, false, true, None);
|
||||
@@ -587,7 +515,6 @@ class C:
|
||||
]
|
||||
);
|
||||
|
||||
// Without header: comments at boundary are rejected by sibling filter
|
||||
let opts_no_header = make_opts(Some(4), 1, false, false, None);
|
||||
let result_no = read_block(content, 1, 2000, opts_no_header).unwrap();
|
||||
assert_eq!(
|
||||
@@ -598,11 +525,8 @@ class C:
|
||||
|
||||
#[test]
|
||||
fn limit_caps_output_size() {
|
||||
// anchor=3 (b=2), max_levels=0, limit=3.
|
||||
// Codex: both up+down each iteration. final_limit = min(3, 3, 6) = 3.
|
||||
// Iter 1: up: push a=1 → [a,b,c...wait]
|
||||
// out starts as [b]. Iter 1: up push foo → [foo, b]. down push c → [foo, b, c].
|
||||
// out.len()=3 → done.
|
||||
// anchor=3 (b = 2), final_limit = min(3, 3, 6) = 3. The first iteration
|
||||
// takes one line upward and one downward, filling the budget.
|
||||
let content = b"def foo():\n a = 1\n b = 2\n c = 3\n d = 4\n e = 5\n";
|
||||
let opts = make_opts(Some(3), 0, false, true, Some(3));
|
||||
let result = read_block(content, 1, 3, opts).unwrap();
|
||||
@@ -621,8 +545,6 @@ class C:
|
||||
assert_eq!(result, vec!["L2: line2"]);
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn anchor_exceeds_file_length_error() {
|
||||
let content = b"one\ntwo\n";
|
||||
@@ -651,19 +573,16 @@ class C:
|
||||
|
||||
#[test]
|
||||
fn trims_leading_trailing_blank_lines() {
|
||||
// Blank lines at the edges of the expansion should be trimmed.
|
||||
let content = b"\ndef foo():\n x = 1\n\n";
|
||||
let opts = make_opts(Some(3), 1, false, true, None);
|
||||
let result = read_block(content, 1, 2000, opts).unwrap();
|
||||
// First and last lines of result should not be blank
|
||||
// A trimmed result never starts or ends with a bare "Ln: " line.
|
||||
assert!(!result.first().unwrap().ends_with(": "));
|
||||
assert!(!result.last().unwrap().ends_with(": "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_uses_trim_start() {
|
||||
// Verify that trimmed() strips only leading whitespace.
|
||||
// A line like " hello " should have trimmed() = "hello "
|
||||
let rec = LineRecord {
|
||||
number: 1,
|
||||
raw: " hello ".to_string(),
|
||||
@@ -676,7 +595,6 @@ class C:
|
||||
|
||||
#[test]
|
||||
fn is_comment_uses_raw_trim() {
|
||||
// Verify is_comment uses raw.trim() (both sides), not trim_start().
|
||||
let rec = LineRecord {
|
||||
number: 1,
|
||||
raw: " // comment ".to_string(),
|
||||
@@ -692,7 +610,6 @@ class C:
|
||||
// anchor=6 (std::cout << "one", indent 12), max_levels=1, min_indent=12-4=8.
|
||||
let opts = make_opts(Some(6), 1, false, true, None);
|
||||
let result = read_block(content, 1, 2000, opts).unwrap();
|
||||
// Should include case 1: and its body
|
||||
assert!(result.iter().any(|l| l.contains("case 1:")));
|
||||
assert!(result.iter().any(|l| l.contains("\"one\"")));
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
//! Codex `read_file` — text file reader in codex `L{n}: {content}` format.
|
||||
//!
|
||||
//! This module ports the codex read_file tool as a separate tool under
|
||||
//! Port of the codex read_file tool, exposed as its own tool under
|
||||
//! `ToolNamespace::Codex`. It supports two modes:
|
||||
//!
|
||||
//! - **Slice mode** — reads a contiguous range of lines (default).
|
||||
//! - **Indentation mode** — reads a block based on indentation structure.
|
||||
//!
|
||||
//! # Submodules
|
||||
//!
|
||||
//! - [`text_utils`] — shared text helpers (char-boundary truncation).
|
||||
//! - [`slice`] — slice-mode reader (exact port of codex `slice::read()`).
|
||||
//! - [`indentation`] — indentation-mode reader (exact port of codex `indentation::*`).
|
||||
//! - [`tool`] — `CodexReadFileTool` implementation, input types, description.
|
||||
|
||||
pub mod indentation;
|
||||
pub mod slice;
|
||||
pub(crate) mod text_utils;
|
||||
pub mod tool;
|
||||
|
||||
// Re-exports for convenience.
|
||||
pub use tool::{CodexReadFileInput, CodexReadFileTool};
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
//! Slice-mode reader — exact port of codex `slice::read()`.
|
||||
//!
|
||||
//! Reads lines from `offset` (1-indexed) up to `limit`, formatting each
|
||||
//! as `L{line_number}: {content}`. Lines are truncated at `MAX_LINE_LENGTH`
|
||||
//! at a char boundary.
|
||||
|
||||
/// Maximum number of characters per line before truncation.
|
||||
pub(crate) const MAX_LINE_LENGTH: usize = 500;
|
||||
|
||||
/// Read a contiguous range of lines from file bytes in slice mode.
|
||||
///
|
||||
/// Returns formatted lines as `L{n}: {content}`, or an error string if
|
||||
/// `offset` exceeds the number of lines in the file.
|
||||
/// Errs when `offset` lies past the last line of the file.
|
||||
pub(crate) fn read_slice(
|
||||
file_bytes: &[u8],
|
||||
offset: usize,
|
||||
@@ -44,17 +36,10 @@ pub(crate) fn read_slice(
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
/// Format a raw byte line: decode as UTF-8 (lossy) and truncate at
|
||||
/// `MAX_LINE_LENGTH` at a char boundary.
|
||||
fn format_line(bytes: &[u8]) -> String {
|
||||
super::text_utils::format_display(bytes)
|
||||
}
|
||||
|
||||
/// Split raw bytes into lines, stripping `\n` and `\r\n` line endings.
|
||||
///
|
||||
/// Every byte sequence separated by `\n` becomes a line. Trailing `\r`
|
||||
/// on each line is also stripped. A final `\n` produces an empty trailing
|
||||
/// entry (matching codex `BufReader::read_until(b'\n')` behavior).
|
||||
fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
|
||||
if bytes.is_empty() {
|
||||
return vec![];
|
||||
@@ -66,7 +51,6 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
|
||||
for i in 0..bytes.len() {
|
||||
if bytes[i] == b'\n' {
|
||||
let mut end = i;
|
||||
// Strip trailing \r for \r\n endings.
|
||||
if end > start && bytes[end - 1] == b'\r' {
|
||||
end -= 1;
|
||||
}
|
||||
@@ -75,7 +59,6 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining bytes after the last \n (or all bytes if no \n found).
|
||||
if start < bytes.len() {
|
||||
let mut end = bytes.len();
|
||||
if end > start && bytes[end - 1] == b'\r' {
|
||||
@@ -83,19 +66,14 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
|
||||
}
|
||||
lines.push(&bytes[start..end]);
|
||||
} else if start == bytes.len() && !bytes.is_empty() && bytes[bytes.len() - 1] == b'\n' {
|
||||
// File ends with \n — BufReader::read_until would NOT produce an
|
||||
// empty trailing line for this case. The codex implementation reads
|
||||
// until EOF and each read_until(b'\n') call consumes the delimiter.
|
||||
// A trailing \n means the last read produces the line before it;
|
||||
// no additional empty line is generated.
|
||||
// So we do NOT push an empty trailing entry here.
|
||||
// Intentionally empty: codex's `read_until(b'\n')` loop consumes the
|
||||
// delimiter along with the line before it, so a file ending in `\n`
|
||||
// yields no empty final line.
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -120,7 +98,6 @@ mod tests {
|
||||
let content = b"\xff\xfe\n";
|
||||
let result = read_slice(content, 1, 10).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
// Non-UTF8 bytes should be replaced with U+FFFD
|
||||
assert!(result[0].contains('\u{FFFD}'));
|
||||
}
|
||||
|
||||
@@ -145,7 +122,6 @@ mod tests {
|
||||
let content = format!("{}\n", long_line);
|
||||
let result = read_slice(content.as_bytes(), 1, 10).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
// Line content should be truncated to MAX_LINE_LENGTH
|
||||
let expected_content = &long_line[..MAX_LINE_LENGTH];
|
||||
assert_eq!(result[0], format!("L1: {}", expected_content));
|
||||
}
|
||||
@@ -166,7 +142,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_file_returns_error() {
|
||||
// Codex behavior: empty file has 0 lines, offset=1 exceeds file length.
|
||||
// Codex treats an empty file as zero lines, so even offset=1 is past
|
||||
// the end.
|
||||
let content = b"";
|
||||
let result = read_slice(content, 1, 10);
|
||||
assert!(result.is_err());
|
||||
@@ -175,14 +152,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn truncation_at_multibyte_char_boundary() {
|
||||
// Create a string that has multi-byte chars near the 500 boundary
|
||||
// Straddle MAX_LINE_LENGTH with a 2-byte char so the cut lands
|
||||
// mid-character.
|
||||
let mut s = "a".repeat(498);
|
||||
s.push('é'); // 2 bytes in UTF-8
|
||||
s.push('é');
|
||||
s.push('x');
|
||||
assert!(s.len() > MAX_LINE_LENGTH);
|
||||
let content = format!("{}\n", s);
|
||||
let result = read_slice(content.as_bytes(), 1, 10).unwrap();
|
||||
// The truncated line should be valid UTF-8 and <= MAX_LINE_LENGTH bytes
|
||||
let line_content = result[0].strip_prefix("L1: ").unwrap();
|
||||
assert!(line_content.len() <= MAX_LINE_LENGTH);
|
||||
assert!(line_content.is_char_boundary(line_content.len()));
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use super::slice::MAX_LINE_LENGTH;
|
||||
|
||||
/// Truncate a string at a char boundary, returning at most `max_bytes`
|
||||
/// bytes. Port of codex `take_bytes_at_char_boundary`.
|
||||
/// Port of codex `take_bytes_at_char_boundary`: at most `max_bytes` bytes,
|
||||
/// cut on a char boundary.
|
||||
pub(crate) fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
@@ -19,7 +19,6 @@ pub(crate) fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
|
||||
&s[..last_ok]
|
||||
}
|
||||
|
||||
/// UTF-8 lossy decode + truncate at MAX_LINE_LENGTH.
|
||||
pub(crate) fn format_display(raw: &[u8]) -> String {
|
||||
let decoded = String::from_utf8_lossy(raw);
|
||||
if decoded.len() > MAX_LINE_LENGTH {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! `CodexReadFileTool` — Tool trait implementation for the codex read_file format.
|
||||
//!
|
||||
//! Reads files via `AsyncFileSystem` and produces output in the codex
|
||||
//! `L{n}: {content}` format. Supports both slice mode and indentation mode.
|
||||
//! `CodexReadFileTool` — reads files in the codex `L{n}: {content}` format,
|
||||
//! in either slice or indentation mode.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -13,14 +11,10 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
use super::{indentation, slice};
|
||||
|
||||
// ─── Description ─────────────────────────────────────────────────────
|
||||
|
||||
/// Tool description — word-for-word copy from codex `create_read_file_tool()` in
|
||||
/// `codex-rs/core/src/tools/spec.rs` (line 1233).
|
||||
const DESCRIPTION: &str = "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes.";
|
||||
|
||||
// ─── Input ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Input for the codex `read_file` tool.
|
||||
///
|
||||
/// Field descriptions match codex `create_read_file_tool()` parameter descriptions.
|
||||
@@ -118,14 +112,9 @@ impl Default for IndentationArgs {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Codex read_file tool — reads files in the codex `L{n}: {content}` format.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CodexReadFileTool;
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for CodexReadFileTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Read
|
||||
@@ -179,12 +168,10 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
|
||||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
// 1. Validate. Codex raises here, but we surface these as a structured
|
||||
// `FileReadError` (a model-facing error) instead of a hard `Err`, so
|
||||
// otherwise-benign validation failures (empty/short files, relative
|
||||
// paths) do not surface as tool-execution failures.
|
||||
// `FileReadError` rides the structured-output path and maps cleanly to
|
||||
// `ReadFileErrorTypes::FILE_READ_ERROR`.
|
||||
// Codex raises on these, but we return a structured `FileReadError`
|
||||
// (which maps to `ReadFileErrorTypes::FILE_READ_ERROR`) instead of a
|
||||
// hard `Err`, so benign failures — empty/short files, relative paths,
|
||||
// out-of-range offsets — reach the model rather than aborting the call.
|
||||
if input.offset == 0 {
|
||||
return Ok(ReadFileOutput::FileReadError(
|
||||
"offset must be a 1-indexed line number".to_string(),
|
||||
@@ -202,7 +189,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Read file via AsyncFileSystem.
|
||||
let fs;
|
||||
{
|
||||
fs = resources.lock().await.require::<FileSystem>()?.0.clone();
|
||||
@@ -217,8 +203,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Branch on mode. Out-of-range / empty-file reads return a structured
|
||||
// `FileReadError` (see note above) instead of a hard `Err`.
|
||||
let collected = match input.mode {
|
||||
ReadMode::Slice => match slice::read_slice(&file_bytes, input.offset, input.limit) {
|
||||
Ok(lines) => lines,
|
||||
@@ -240,15 +224,12 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Build formatted output (L{n}: {content} lines joined by \n).
|
||||
let content = collected.join("\n");
|
||||
|
||||
// 5. Build raw_output — the unformatted file content for the read
|
||||
// range. This matches the kigi ReadFileTool semantics where
|
||||
// raw_output is the actual file text without line-number prefixes.
|
||||
// Kigi's `ReadFileTool` semantics: `raw_output` is the file text with
|
||||
// no line-number prefixes.
|
||||
let raw_output = String::from_utf8_lossy(&file_bytes).into_owned();
|
||||
|
||||
// 6. Compute total lines.
|
||||
let total_lines = file_bytes.iter().filter(|&&b| b == b'\n').count()
|
||||
+ if file_bytes.last() != Some(&b'\n') && !file_bytes.is_empty() {
|
||||
1
|
||||
@@ -256,7 +237,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
|
||||
0
|
||||
};
|
||||
|
||||
// 7. Return.
|
||||
Ok(ReadFileOutput::FileContent(FileContent {
|
||||
content,
|
||||
content_concise: None,
|
||||
@@ -280,7 +260,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Set up Resources with real filesystem for tests.
|
||||
fn test_resources(cwd: &std::path::Path) -> Resources {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(cwd.to_path_buf()));
|
||||
@@ -289,9 +268,6 @@ mod tests {
|
||||
resources
|
||||
}
|
||||
|
||||
/// Build a runtime `ToolCallContext` with the given shared resources.
|
||||
// ── Slice mode tests ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn slice_reads_requested_range() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -337,8 +313,6 @@ mod tests {
|
||||
indentation: None,
|
||||
};
|
||||
|
||||
// Out-of-range reads are surfaced as a structured `FileReadError` (a
|
||||
// model-facing error), not a hard `Err`.
|
||||
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -352,9 +326,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_file_returns_read_error() {
|
||||
// An empty file has 0 lines, so the default offset=1 is out of range.
|
||||
// Codex treats this as a read error; we surface it as a structured
|
||||
// FileReadError instead of a hard Err.
|
||||
// An empty file has 0 lines, so even the default offset=1 is out of
|
||||
// range.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let file_path = tmp.path().join("empty.txt");
|
||||
std::fs::write(&file_path, "").unwrap();
|
||||
@@ -437,8 +410,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Indentation mode tests ───────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn indentation_mode_captures_block() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -480,13 +451,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation tests ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn indentation_anchor_past_eof_returns_read_error() {
|
||||
// Indentation-mode range errors flow through the same read_block match
|
||||
// arm as slice mode, so they must also surface as a structured
|
||||
// FileReadError rather than a hard Err.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let file_path = tmp.path().join("code.py");
|
||||
std::fs::write(&file_path, "def foo():\n x = 1\n").unwrap();
|
||||
|
||||
@@ -367,6 +367,9 @@ fn rule_matches_read_path(
|
||||
read_path: &Path,
|
||||
) -> bool {
|
||||
match &rule.kind {
|
||||
// An always-apply rule at the workspace root applies everywhere and so
|
||||
// has no read scope; only a nested one is a reminder, and only for
|
||||
// reads beneath its own directory.
|
||||
CursorRuleKind::Global => {
|
||||
rule.scope_dir != workspace_root && read_path.starts_with(&rule.scope_dir)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! File operation lock manager — serializes concurrent file operations.
|
||||
//!
|
||||
//! diagnostics for each file. Multiple reads for *different* paths can proceed
|
||||
//! concurrently; reads for the *same* path are serialized.
|
||||
//! - **Exclusive lock** (`wait_for_exclusive_lock`): used by `Write` and
|
||||
//! `StrReplace` before mutating files. Blocks all per-path locks and
|
||||
//! vice-versa.
|
||||
//! A per-path lock (`wait_for_lock`) lets operations on *different* paths run
|
||||
//! concurrently while serializing those on the *same* path. An exclusive lock
|
||||
//! (`wait_for_exclusive_lock`) blocks all per-path locks and vice-versa.
|
||||
//!
|
||||
//! The queue is FIFO with priority inversion avoidance: per-path waiters
|
||||
//! will not jump ahead of a queued exclusive waiter, preventing writer
|
||||
@@ -14,7 +12,6 @@ use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
|
||||
/// Shared file operation lock manager stored in tool shared resources.
|
||||
#[derive(Clone)]
|
||||
pub struct FileOperationLockManager {
|
||||
inner: Arc<Mutex<LockInner>>,
|
||||
@@ -47,12 +44,7 @@ impl FileOperationLockManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire a per-path lock. Blocks if:
|
||||
/// - An exclusive lock is active, OR
|
||||
/// - The same path is already locked, OR
|
||||
/// - An exclusive waiter is ahead in the queue.
|
||||
///
|
||||
/// Returns a guard that releases the lock on drop.
|
||||
/// Acquire a per-path lock; the returned guard releases it on drop.
|
||||
pub async fn wait_for_lock(&self, path: &str) -> FileOperationLockGuard {
|
||||
let rx = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
@@ -74,7 +66,8 @@ impl FileOperationLockManager {
|
||||
};
|
||||
|
||||
if let Some(rx) = rx {
|
||||
// Wait for our turn (ignore error — sender dropped means lock manager was dropped).
|
||||
// A send error means the manager was dropped, so there is no lock
|
||||
// left to wait for.
|
||||
let _ = rx.await;
|
||||
}
|
||||
|
||||
@@ -84,10 +77,7 @@ impl FileOperationLockManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire an exclusive lock. Blocks until all per-path locks are released
|
||||
/// and no other exclusive lock is active.
|
||||
///
|
||||
/// Returns a guard that releases the lock on drop.
|
||||
/// Acquire an exclusive lock; the returned guard releases it on drop.
|
||||
pub async fn wait_for_exclusive_lock(&self) -> FileOperationLockGuard {
|
||||
let rx = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
@@ -125,7 +115,6 @@ enum LockKind {
|
||||
Exclusive,
|
||||
}
|
||||
|
||||
/// RAII guard that releases the lock when dropped.
|
||||
pub struct FileOperationLockGuard {
|
||||
manager: FileOperationLockManager,
|
||||
kind: LockKind,
|
||||
@@ -135,7 +124,7 @@ impl Drop for FileOperationLockGuard {
|
||||
fn drop(&mut self) {
|
||||
let manager = self.manager.clone();
|
||||
let kind = std::mem::replace(&mut self.kind, LockKind::Exclusive);
|
||||
// Use `spawn` to release asynchronously — `drop` can't be async.
|
||||
// `drop` cannot be async, so release on a spawned task.
|
||||
tokio::spawn(async move {
|
||||
let mut inner = manager.inner.lock().await;
|
||||
match kind {
|
||||
@@ -158,11 +147,9 @@ impl LockInner {
|
||||
.any(|w| matches!(w, QueuedWaiter::Exclusive { .. }))
|
||||
}
|
||||
|
||||
/// Process the wait queue, granting locks to eligible waiters.
|
||||
///
|
||||
/// If a waiter's receiver has been dropped (task cancelled), the send
|
||||
/// will fail. In that case, we undo the lock grant and continue to the
|
||||
/// next waiter. This prevents phantom locks from cancelled tool calls.
|
||||
/// A failing send means the waiter's receiver is gone (its tool call was
|
||||
/// cancelled); the grant is undone and the next waiter tried, so cancelled
|
||||
/// calls cannot leave phantom locks behind.
|
||||
fn process_queue(&mut self) {
|
||||
while let Some(front) = self.wait_queue.front() {
|
||||
match front {
|
||||
@@ -173,12 +160,10 @@ impl LockInner {
|
||||
if let Some(QueuedWaiter::Exclusive { tx }) = self.wait_queue.pop_front() {
|
||||
self.exclusive_lock_active = true;
|
||||
if tx.send(()).is_err() {
|
||||
// Receiver dropped (cancelled) — undo the grant.
|
||||
self.exclusive_lock_active = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Exclusive lock granted — stop processing.
|
||||
break;
|
||||
}
|
||||
QueuedWaiter::File { path, .. } => {
|
||||
@@ -189,7 +174,6 @@ impl LockInner {
|
||||
if let Some(QueuedWaiter::File { tx, .. }) = self.wait_queue.pop_front() {
|
||||
self.locked_files.insert(path.clone());
|
||||
if tx.send(()).is_err() {
|
||||
// Receiver dropped (cancelled) — undo the grant.
|
||||
self.locked_files.remove(&path);
|
||||
continue;
|
||||
}
|
||||
@@ -218,7 +202,7 @@ mod tests {
|
||||
order2.lock().await.push("2-acquired");
|
||||
});
|
||||
|
||||
// Give spawned task time to queue.
|
||||
// Let the spawned task reach the queue before the guard is released.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
order.lock().await.push("1-releasing");
|
||||
drop(guard1);
|
||||
@@ -233,7 +217,6 @@ mod tests {
|
||||
let mgr = FileOperationLockManager::new();
|
||||
let _guard_a = mgr.wait_for_lock("a.ts").await;
|
||||
|
||||
// Different path should acquire immediately.
|
||||
let mgr2 = mgr.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _guard_b = mgr2.wait_for_lock("b.ts").await;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Formatting functions for AskUserQuestion tool results.
|
||||
//!
|
||||
//! Each function produces the **exact** model-visible string for one of the
|
||||
//! four user-action paths.
|
||||
//!
|
||||
//! The tests below pin the exact output strings and serve as the
|
||||
//! source-of-truth specification.
|
||||
//! four user-action paths: A accepted, B chat about this, C skip interview,
|
||||
//! D cancel. The tests below pin those strings and are the source of truth.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -13,32 +11,25 @@ use indexmap::IndexMap;
|
||||
use super::Question;
|
||||
use super::types::QuestionAnnotation;
|
||||
|
||||
// ── Path D: Cancel ──────────────────────────────────────────────────────
|
||||
|
||||
/// Tool result text when the user cancels / dismisses the question UI.
|
||||
///
|
||||
/// Cancel is a normal user decision, not a tool failure, so this is a
|
||||
/// purpose-built message rather than a generic permission-denial string.
|
||||
pub const CANCEL_TEXT: &str = "User declined to answer the questions. Continue with the task using your best judgment, or ask different questions.";
|
||||
|
||||
// ── Path A: Accepted ────────────────────────────────────────────────────
|
||||
|
||||
/// Format the tool result for Path A (user accepted and submitted answers).
|
||||
///
|
||||
/// Produces the accepted-answers tool result:
|
||||
///
|
||||
/// ```text
|
||||
/// User has answered your questions: "<q>"="<label>" ..., "<q>"="<label>" .... You can now continue with the user's answers in mind.
|
||||
/// ```
|
||||
///
|
||||
/// Rules:
|
||||
/// - Only answered questions appear (unanswered are omitted by the caller).
|
||||
/// Wire-format expectations:
|
||||
/// - Unanswered questions are omitted by the caller, so `answers` holds only
|
||||
/// answered ones.
|
||||
/// - Multi-select: each selected label is its own `Vec` element on the
|
||||
/// wire; this function joins them with `, ` at format time.
|
||||
/// - Freeform-only: a single-element vec containing `"Other"`, free text
|
||||
/// in `annotations[q].notes`.
|
||||
/// - Preview is appended only when present in annotations.
|
||||
/// - Notes are appended only when present in annotations.
|
||||
/// - Questions/labels are interpolated raw (no escaping).
|
||||
pub fn format_accepted_tool_result(
|
||||
answers: &IndexMap<String, Vec<String>>,
|
||||
@@ -71,8 +62,6 @@ pub fn format_accepted_tool_result(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Alternate id-keyed tool-result formatting ────
|
||||
|
||||
/// Format the tool result in the alternate id-keyed shape (Path A).
|
||||
///
|
||||
/// Answers are keyed by **id**, one question per line, with no trailing
|
||||
@@ -84,28 +73,15 @@ pub fn format_accepted_tool_result(
|
||||
/// Question <qid>: Selected option(s) <oid>(, <oid>)*
|
||||
/// ```
|
||||
///
|
||||
/// Examples:
|
||||
///
|
||||
/// - Single question, single-select:
|
||||
/// `User questions responses:\nQuestion demo_pick: Selected option(s) a`
|
||||
/// - Three questions, last with `allow_multiple: true` (one selection):
|
||||
/// `User questions responses:\nQuestion q1: Selected option(s) tea\nQuestion q2: Selected option(s) code\nQuestion q3: Selected option(s) tests`
|
||||
///
|
||||
/// Multi-select labels arrive as separate `Vec` elements; this function
|
||||
/// joins their resolved ids with `, ` (`Selected option(s) a, b, c`).
|
||||
/// The multi-select join shape is exercised by the test below.
|
||||
///
|
||||
/// `input_questions` carries both `id` and the option `label`/`id` map
|
||||
/// so we can resolve the answer values (which arrive label-keyed from
|
||||
/// the client) back to the option ids.
|
||||
/// the client) back to the option ids. It also fixes the output order;
|
||||
/// questions missing from `answers` are skipped.
|
||||
///
|
||||
/// `annotations` carries per-question freeform notes (the text the user
|
||||
/// typed when picking the freeform "Other" path or dismissing). When no
|
||||
/// option labels resolve to ids and `notes` is non-empty, the result is
|
||||
/// `Question <qid>: <raw_text>` (no `Selected option(s)` prefix).
|
||||
///
|
||||
/// Question order follows `input_questions`. Unanswered questions are
|
||||
/// omitted -- only answered questions appear in the result.
|
||||
pub fn format_id_keyed_accepted_tool_result(
|
||||
input_questions: &[super::Question],
|
||||
answers: &IndexMap<String, Vec<String>>,
|
||||
@@ -116,10 +92,9 @@ pub fn format_id_keyed_accepted_tool_result(
|
||||
.filter_map(|q| {
|
||||
let qid = q.id.as_ref()?;
|
||||
let labels = answers.get(&q.question)?;
|
||||
// Each selected label is its own `Vec` element (the wire
|
||||
// format no longer joins labels with `", "`), so we look each
|
||||
// one up directly. No splitting, no ambiguity around labels
|
||||
// that contain commas or share substrings with other labels.
|
||||
// Each selected label is its own `Vec` element, so each is looked
|
||||
// up whole: labels containing `", "` or sharing a substring with
|
||||
// another label cannot be mismatched.
|
||||
let oids: Vec<&str> = labels
|
||||
.iter()
|
||||
.filter_map(|label| {
|
||||
@@ -130,8 +105,8 @@ pub fn format_id_keyed_accepted_tool_result(
|
||||
})
|
||||
.collect();
|
||||
if oids.is_empty() {
|
||||
// Freeform / dismissed: emit the raw text from the freeform
|
||||
// input directly after `Question <qid>: `.
|
||||
// Freeform or dismissed: the typed text stands in for an
|
||||
// option id.
|
||||
let notes = annotations
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(&q.question))
|
||||
@@ -159,13 +134,8 @@ pub fn format_id_keyed_accepted_tool_result(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Path B: Chat about this (plan mode) ─────────────────────────────────
|
||||
|
||||
/// Format the tool result for Path B ("Chat about this" / respond-to-agent).
|
||||
///
|
||||
/// Iterates ALL original questions. Answered questions show their label;
|
||||
/// unanswered questions show "(No answer provided)".
|
||||
///
|
||||
/// Whitespace is intentional:
|
||||
/// - Lines 2-4 and "Questions asked:" have 4-space indentation.
|
||||
/// - Question bullets have no indentation.
|
||||
@@ -197,11 +167,10 @@ pub fn format_chat_about_this(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Path C: Skip interview (plan mode) ──────────────────────────────────
|
||||
|
||||
/// Format the tool result for Path C ("Skip interview and plan immediately").
|
||||
///
|
||||
/// Same per-question format as Path B, but different header and NO indentation.
|
||||
/// Same per-question format as Path B, but a different header and no
|
||||
/// indentation.
|
||||
pub fn format_skip_interview(
|
||||
questions: &[Question],
|
||||
partial_answers: &HashMap<String, String>,
|
||||
@@ -227,15 +196,11 @@ pub fn format_skip_interview(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::QuestionOption;
|
||||
use super::*;
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn make_question(text: &str, labels: &[&str]) -> Question {
|
||||
Question {
|
||||
question: text.to_string(),
|
||||
@@ -253,8 +218,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path A: format_accepted_tool_result ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_accepted_single_no_annotations() {
|
||||
let mut answers = IndexMap::new();
|
||||
@@ -316,7 +279,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_accepted_freeform_only() {
|
||||
// Freeform-only: label is "Other", typed text in annotations.notes
|
||||
let mut answers = IndexMap::new();
|
||||
answers.insert("Which database?".to_string(), vec!["Other".to_string()]);
|
||||
|
||||
@@ -369,11 +331,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_accepted_partial() {
|
||||
// Only answered questions appear. Unanswered questions are omitted by the caller
|
||||
// (the answers IndexMap simply doesn't contain them).
|
||||
let mut answers = IndexMap::new();
|
||||
answers.insert("Which database?".to_string(), vec!["Redis".to_string()]);
|
||||
// "Which framework?" is unanswered => not in the map
|
||||
|
||||
let result = format_accepted_tool_result(&answers, &None);
|
||||
assert_eq!(
|
||||
@@ -382,11 +341,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Alternate id-keyed formatter tests ────────────────────
|
||||
//
|
||||
// Pin both result strings (single question and three questions) so any
|
||||
// drift in the formatter trips a deterministic failure. Update the
|
||||
// literal strings deliberately if the wire format ever changes.
|
||||
fn id_keyed_q(qid: &str, prompt: &str, opts: &[(&str, &str)]) -> super::super::Question {
|
||||
super::super::Question {
|
||||
question: prompt.to_string(),
|
||||
@@ -477,9 +431,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_id_keyed_multi_select_inferred_csv() {
|
||||
// Multi-select with multiple selections joins option ids with ", "
|
||||
// at format time. Each selected label arrives as its own Vec
|
||||
// element (the wire format no longer joins them).
|
||||
let questions = vec![id_keyed_q(
|
||||
"q3",
|
||||
"What do you lean on before a push? (pick any)",
|
||||
@@ -528,12 +479,8 @@ mod tests {
|
||||
assert_eq!(result, "User questions responses:");
|
||||
}
|
||||
|
||||
/// Freeform/dismiss:
|
||||
/// when the user dismisses or types freeform text instead of picking
|
||||
/// an option, the wire format emits the raw text directly after
|
||||
/// `Question <qid>: ` with NO `Selected option(s)` prefix. The pager
|
||||
/// sends `answers["..."] = ["Other"]` plus the typed text in
|
||||
/// `annotations[q].notes`; the formatter falls through to the notes.
|
||||
/// The pager sends `answers["..."] = ["Other"]` plus the typed text in
|
||||
/// `annotations[q].notes`, which is what makes the notes fallback fire.
|
||||
#[test]
|
||||
fn format_id_keyed_freeform_dismissal_uses_notes_without_selected_prefix() {
|
||||
let questions = vec![id_keyed_q(
|
||||
@@ -562,9 +509,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Freeform with no notes (just `["Other"]` and no annotation) is
|
||||
/// indistinguishable from a no-answer to the formatter, so the
|
||||
/// question is dropped (matching the `oids.is_empty()` branch).
|
||||
/// Freeform with no notes is indistinguishable from a no-answer to the
|
||||
/// formatter, so the question is dropped.
|
||||
#[test]
|
||||
fn format_id_keyed_freeform_without_notes_is_dropped() {
|
||||
let questions = vec![id_keyed_q("q1", "Pick", &[("a", "A")])];
|
||||
@@ -576,7 +522,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_accepted_special_chars() {
|
||||
// Quotes and newlines in labels appear verbatim (no escaping)
|
||||
let mut answers = IndexMap::new();
|
||||
answers.insert(
|
||||
"Which \"option\"?".to_string(),
|
||||
@@ -590,8 +535,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path B: format_chat_about_this ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_chat_about_this_mixed() {
|
||||
let questions = vec![
|
||||
@@ -639,8 +582,6 @@ The user wants to clarify these questions.
|
||||
assert!(result.contains("- \"Q2?\"\n (No answer provided)"));
|
||||
}
|
||||
|
||||
// ── Path C: format_skip_interview ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_skip_interview_all_answered() {
|
||||
let questions = vec![
|
||||
@@ -683,24 +624,18 @@ Questions asked and answers provided:
|
||||
|
||||
#[test]
|
||||
fn format_skip_interview_no_indentation() {
|
||||
// Path C has NO indentation on any header lines (unlike Path B)
|
||||
let questions = vec![make_question("Q?", &["A"])];
|
||||
let result = format_skip_interview(&questions, &HashMap::new());
|
||||
|
||||
// First line has no leading spaces
|
||||
let first_line = result.lines().next().unwrap();
|
||||
assert!(!first_line.starts_with(' '));
|
||||
|
||||
// Second line has no leading spaces
|
||||
let second_line = result.lines().nth(1).unwrap();
|
||||
assert!(!second_line.starts_with(' '));
|
||||
|
||||
// "Questions asked" line has no leading spaces
|
||||
assert!(result.contains("\nQuestions asked and answers provided:\n"));
|
||||
}
|
||||
|
||||
// ── Path D: CANCEL_TEXT ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_cancel() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
//! `AskUserQuestion` tool — new architecture (`Tool` trait).
|
||||
//! `AskUserQuestion` tool.
|
||||
//!
|
||||
//! Interactive Q&A tool that presents the user with structured questions and
|
||||
//! option sets. In plan mode it serves as the **interview mechanism** — the
|
||||
//! agent clarifies requirements, disambiguates approaches, and gets user input
|
||||
//! on design decisions before finalizing the plan. Outside plan mode it is a
|
||||
//! general-purpose tool for gathering user preferences during implementation.
|
||||
//! Presents the user with structured questions and option sets. In plan mode
|
||||
//! it is the interview mechanism the agent uses to clarify requirements before
|
||||
//! finalizing a plan; outside plan mode it gathers preferences during
|
||||
//! implementation.
|
||||
//!
|
||||
//! ## How It Works
|
||||
//! Flow: the tool hands a [`UserQuestionRequest`] to the session-owned
|
||||
//! coordinator in `kigi-shell` over an mpsc channel, emits a
|
||||
//! `UserQuestionAsked` notification for observers, then blocks on a oneshot
|
||||
//! until the coordinator's ACP `ext_method` round-trip with the client
|
||||
//! resolves (or the wait budget elapses).
|
||||
//!
|
||||
//! 1. The agent calls `AskUserQuestion` with an array of structured questions
|
||||
//! (each with options, optional preview, optional multi_select).
|
||||
//! 2. The tool sends a `UserQuestionAsked` **notification** to the gateway/client
|
||||
//! carrying the full question payload as JSON.
|
||||
//! 3. The tool returns `AskUserQuestionOutput::QuestionsSent` to the model as
|
||||
//! an immediate confirmation.
|
||||
//! 4. The client presents the question UI, collects user answers, and injects
|
||||
//! them back into the conversation as the tool result. This client-side
|
||||
//! round-trip is handled by the orchestration layer, not by this tool.
|
||||
//!
|
||||
//! ## Plan-Mode Interview Actions
|
||||
//!
|
||||
//! When called during plan mode, the client can present two extra buttons:
|
||||
//! - **"Respond to agent"** — partial answers, agent reformulates questions
|
||||
//! - **"Finish plan interview"** — agent stops asking, proceeds with what it has
|
||||
//!
|
||||
//! These are client-side behaviors that produce different tool-result strings;
|
||||
//! the tool itself is identical in and out of plan mode.
|
||||
//! In plan mode the client offers two extra actions — "Chat about this"
|
||||
//! (partial answers, agent reformulates) and "Skip interview" (agent proceeds
|
||||
//! with what it has). Those only change the tool-result text; the tool itself
|
||||
//! behaves identically in and out of plan mode.
|
||||
|
||||
pub mod format;
|
||||
pub mod types;
|
||||
@@ -42,37 +31,27 @@ use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::resources::{NotificationHandle, SharedResources};
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
/// Migration fallback: when `true`, a missing `UserQuestionSender` falls
|
||||
/// back to the old fire-and-forget `QuestionsSent` behavior with a warning.
|
||||
/// Set to `false` (or delete entirely) once the shell coordinator is wired
|
||||
/// up in TS-03 and confirmed working.
|
||||
/// TODO: set to `false` and drop [`AskUserQuestionTool::fallback_fire_and_forget`]
|
||||
/// once the shell coordinator (TS-03) is wired up. While `true`, a missing
|
||||
/// `UserQuestionSender` degrades to fire-and-forget `QuestionsSent` instead of
|
||||
/// failing the tool call.
|
||||
const MIGRATION_FALLBACK: bool = true;
|
||||
|
||||
/// Default max time to wait for the user to answer the questionnaire (all
|
||||
/// questions in this tool call share one timer): 30 minutes. On expiry the
|
||||
/// tool returns the same skipped/cancel text as a user dismiss
|
||||
/// (`CANCEL_TEXT`), not a tool failure.
|
||||
///
|
||||
/// The shell resolves `[toolset.ask_user_question]` across its config tiers
|
||||
/// and injects the result as [`AskUserQuestionParams`]; when no resolved
|
||||
/// params are injected, `KIGI_ASK_USER_QUESTION_TIMEOUT_SECS` (positive
|
||||
/// integer seconds) still overrides this default directly —
|
||||
/// e.g. `KIGI_ASK_USER_QUESTION_TIMEOUT_SECS=8` for tests / TUI repro.
|
||||
/// Default wait budget for one questionnaire. On expiry the tool returns the
|
||||
/// same text as a user dismiss (`CANCEL_TEXT`), not a tool failure.
|
||||
pub const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||
|
||||
/// Default for `timeout_enabled` across every resolver tier and settings
|
||||
/// surface: the questionnaire timer is armed unless something disarms it.
|
||||
/// Single source — the shell resolver's `.default(...)` and the pager's
|
||||
/// settings registry both anchor on this const.
|
||||
/// surface. Single source — the shell resolver's `.default(...)` and the
|
||||
/// pager's settings registry both anchor on this const.
|
||||
pub const DEFAULT_ASK_USER_QUESTION_TIMEOUT_ENABLED: bool = true;
|
||||
|
||||
/// Env var: override [`RESPONSE_TIMEOUT`] with a duration in **seconds**.
|
||||
/// Env var overriding [`RESPONSE_TIMEOUT`], in **seconds**.
|
||||
pub const RESPONSE_TIMEOUT_ENV: &str = "KIGI_ASK_USER_QUESTION_TIMEOUT_SECS";
|
||||
|
||||
/// Parse the [`RESPONSE_TIMEOUT_ENV`] override (positive integer seconds).
|
||||
/// Invalid or non-positive values are warned and treated as unset. Single
|
||||
/// source for this parse — the shell's env tier calls it too, so the two
|
||||
/// resolutions can't drift.
|
||||
/// Invalid or non-positive values are warned about and treated as unset.
|
||||
/// Single source for this parse — the shell's env tier calls it too, so the
|
||||
/// two resolutions can't drift.
|
||||
pub fn response_timeout_env_secs() -> Option<u64> {
|
||||
let raw = std::env::var(RESPONSE_TIMEOUT_ENV).ok()?;
|
||||
match raw.trim().parse::<u64>() {
|
||||
@@ -88,29 +67,28 @@ pub fn response_timeout_env_secs() -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective wait budget for one questionnaire (env override or default).
|
||||
/// Env override if set, otherwise [`RESPONSE_TIMEOUT`].
|
||||
pub fn response_timeout() -> std::time::Duration {
|
||||
response_timeout_env_secs()
|
||||
.map(std::time::Duration::from_secs)
|
||||
.unwrap_or(RESPONSE_TIMEOUT)
|
||||
}
|
||||
|
||||
/// Runtime-configurable parameters for the `ask_user_question` tool,
|
||||
/// injected via `Params<AskUserQuestionParams>` in `SharedResources`.
|
||||
/// Runtime-configurable parameters, injected via
|
||||
/// `Params<AskUserQuestionParams>` in `SharedResources`.
|
||||
///
|
||||
/// The shell resolves `[toolset.ask_user_question]` across requirements >
|
||||
/// env > user `config.toml` > managed > remote feature config and injects the
|
||||
/// concrete result. All fields are optional — `None` means "unset", which
|
||||
/// preserves the legacy env→default budget, so registry consumers that never
|
||||
/// resolve config (workspace toolset) keep today's behavior.
|
||||
/// concrete result. Every field is optional, and `None` means "unset" — that
|
||||
/// falls back to the env→default budget, so registry consumers that never
|
||||
/// resolve config (workspace toolset) still get a sane timeout.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AskUserQuestionParams {
|
||||
/// `Some(false)` disarms the questionnaire timer entirely (wait forever
|
||||
/// for an answer). `None`/`Some(true)` keep the timer armed.
|
||||
#[serde(default)]
|
||||
pub timeout_enabled: Option<bool>,
|
||||
/// Wait budget in seconds when the timer is armed (positive integer).
|
||||
/// `None` falls back to the env override / [`RESPONSE_TIMEOUT`].
|
||||
/// Positive integer; `None` falls back to [`response_timeout`].
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
@@ -118,7 +96,7 @@ pub struct AskUserQuestionParams {
|
||||
crate::register_resource!("kigi", "AskUserQuestion", AskUserQuestionParams);
|
||||
|
||||
impl AskUserQuestionParams {
|
||||
/// Effective wait budget: `Some(duration)` = bounded, `None` = wait forever.
|
||||
/// `Some(duration)` = bounded wait, `None` = wait forever.
|
||||
pub fn wait_budget(&self) -> Option<std::time::Duration> {
|
||||
if !self
|
||||
.timeout_enabled
|
||||
@@ -144,23 +122,19 @@ impl AskUserQuestionParams {
|
||||
/// A single option within a question.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct QuestionOption {
|
||||
/// Option text shown to the user; a few words at most.
|
||||
#[schemars(description = "Option text shown to the user. A few words at most.")]
|
||||
pub label: String,
|
||||
|
||||
/// What picking this option means or implies.
|
||||
#[schemars(description = "What picking this option means or implies.")]
|
||||
pub description: String,
|
||||
|
||||
/// Optional content shown while the option is focused — mockups, code
|
||||
/// snippets, anything the user should compare. Single-select only.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(
|
||||
description = "Optional content shown while the option is focused — mockups, code snippets, anything the user should compare. Single-select questions only."
|
||||
)]
|
||||
pub preview: Option<String>,
|
||||
|
||||
/// Opaque id; hidden from the model. Kigi callers leave it `None`.
|
||||
/// Opaque id, hidden from the model. Kigi callers leave it `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(skip)]
|
||||
pub id: Option<String>,
|
||||
@@ -170,17 +144,14 @@ pub struct QuestionOption {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Question {
|
||||
/// The question to ask, phrased as a full question.
|
||||
#[schemars(description = "The question to ask, phrased as a full question.")]
|
||||
pub question: String,
|
||||
|
||||
/// The choices for this question.
|
||||
#[schemars(description = "The choices for this question.")]
|
||||
pub options: Vec<QuestionOption>,
|
||||
|
||||
/// Let the user pick more than one option (default false).
|
||||
// Model-facing schema name is snake_case (`multi_select`); deserialize also
|
||||
// accepts the legacy/ACP `multiSelect` so the shared `Question` type stays
|
||||
// The model-facing schema name is snake_case (`multi_select`), but
|
||||
// deserialization also accepts `multiSelect` so this shared type stays
|
||||
// wire-compatible with the camelCase ACP ext_method.
|
||||
#[serde(
|
||||
default,
|
||||
@@ -193,40 +164,28 @@ pub struct Question {
|
||||
)]
|
||||
pub multi_select: Option<bool>,
|
||||
|
||||
/// See `QuestionOption.id`. Hidden from the JSON schema.
|
||||
/// See `QuestionOption::id`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(skip)]
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
/// Input for the `AskUserQuestion` tool.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct AskUserQuestionInput {
|
||||
/// The questions to ask, each with its own options. At least one question
|
||||
/// is required.
|
||||
#[schemars(description = "The questions to ask, each with its own options.")]
|
||||
pub questions: Vec<Question>,
|
||||
|
||||
/// Internal flag: when `true`, the tool result is formatted in the
|
||||
/// alternate shape (referenced by id, not label).
|
||||
/// Skipped on the wire and from the JSON schema so the model never
|
||||
/// sees or controls this field.
|
||||
/// Formats the tool result keyed by option id rather than label. Skipped
|
||||
/// on the wire and from the JSON schema so the model never sees or
|
||||
/// controls it.
|
||||
#[serde(default, skip)]
|
||||
#[schemars(skip)]
|
||||
pub use_id_keyed_format: bool,
|
||||
}
|
||||
|
||||
/// `AskUserQuestion` tool.
|
||||
///
|
||||
/// Blocks inside `run()` until the user responds or the configured wait
|
||||
/// budget elapses for the whole questionnaire (default [`RESPONSE_TIMEOUT`],
|
||||
/// 30 minutes). Sends a request over an in-process mpsc channel to a
|
||||
/// session-owned coordinator (in kigi-shell), which performs an ACP
|
||||
/// `ext_method` round-trip to the client/pager. The response is sent back
|
||||
/// over a oneshot channel and formatted into the model-visible tool result.
|
||||
///
|
||||
/// Params: [`AskUserQuestionParams`] — timeout policy resolved by the shell
|
||||
/// across its config tiers; unset fields keep the legacy env→default budget.
|
||||
/// `run()` blocks until the user responds or the wait budget elapses for the
|
||||
/// whole questionnaire, and the timeout policy comes from
|
||||
/// [`AskUserQuestionParams`].
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AskUserQuestionTool;
|
||||
|
||||
@@ -259,12 +218,9 @@ impl crate::types::tool_metadata::ToolMetadata for AskUserQuestionTool {
|
||||
}
|
||||
|
||||
impl AskUserQuestionTool {
|
||||
/// Fire-and-forget fallback used during migration when
|
||||
/// `UserQuestionSender` is not yet injected by the shell.
|
||||
///
|
||||
/// This preserves the old behavior: send a notification, return
|
||||
/// `QuestionsSent`. Remove this method when `MIGRATION_FALLBACK` is
|
||||
/// set to `false`.
|
||||
/// Fallback for when the shell has not injected a `UserQuestionSender`:
|
||||
/// send the notification and return `QuestionsSent` without waiting for an
|
||||
/// answer. Gated on [`MIGRATION_FALLBACK`].
|
||||
async fn fallback_fire_and_forget(
|
||||
&self,
|
||||
input: &AskUserQuestionInput,
|
||||
@@ -363,7 +319,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 1: Validate unique question text ───────────────────────
|
||||
{
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for q in &input.questions {
|
||||
@@ -376,7 +331,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Obtain UserQuestionSender ───────────────────────────
|
||||
let sender = {
|
||||
let res = resources.lock().await;
|
||||
res.get::<UserQuestionSender>().cloned()
|
||||
@@ -401,10 +355,8 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 3: Create oneshot ──────────────────────────────────────
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
// ── Step 4: Send UserQuestionRequest ────────────────────────────
|
||||
let request = types::UserQuestionRequest {
|
||||
tool_call_id: ctx.call_id.as_str().to_owned(),
|
||||
questions: input.questions.clone(),
|
||||
@@ -418,7 +370,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Step 5: Emit UserQuestionAsked + read the wait budget ───────
|
||||
let wait = {
|
||||
let questions_json = serde_json::to_value(&input.questions)
|
||||
.unwrap_or_else(|_| serde_json::Value::Array(vec![]));
|
||||
@@ -429,8 +380,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
questions_json,
|
||||
});
|
||||
}
|
||||
// Shell-injected params win; absent or unset fields keep the legacy
|
||||
// env→default budget so non-shell registry consumers are unchanged.
|
||||
res.get::<crate::types::resources::Params<AskUserQuestionParams>>()
|
||||
.map(|p| p.0)
|
||||
.unwrap_or_default()
|
||||
@@ -442,11 +391,10 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
"Asked user questions, blocking for response"
|
||||
);
|
||||
|
||||
// ── Step 6: Block on the oneshot result (whole batch, one timer) ─
|
||||
// A single pending-decision timeout covers the questionnaire, not per
|
||||
// question: N questions in one call share one wait.
|
||||
// A `None` budget (`timeout_enabled = false`) runs the same await with
|
||||
// no timer, normalized into the timed shape so one match handles both.
|
||||
// One timer covers the whole questionnaire, not each question: N
|
||||
// questions in one call share a single wait. A `None` budget
|
||||
// (`timeout_enabled = false`) awaits with no timer at all, wrapped in
|
||||
// the timed shape so one match arm handles both.
|
||||
let outcome = match wait {
|
||||
Some(dur) => tokio::time::timeout(dur, result_rx).await,
|
||||
None => Ok(result_rx.await),
|
||||
@@ -465,17 +413,16 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
|
||||
timeout_secs = ?wait.map(|d| d.as_secs()),
|
||||
"User question timed out; continuing without answers"
|
||||
);
|
||||
// Drop the oneshot receiver on return. The shell coordinator
|
||||
// races `result_tx.closed()` against ACP so it unblocks and
|
||||
// can open the next questionnaire (stale UI is cancelled when
|
||||
// a new ext_method arrives). Same model text as cancel.
|
||||
// Returning drops the oneshot receiver. The shell coordinator
|
||||
// races `result_tx.closed()` against ACP, so it unblocks and
|
||||
// can open the next questionnaire; stale UI is dismissed when
|
||||
// the new ext_method arrives.
|
||||
return Ok(AskUserQuestionOutput::UserAnswered {
|
||||
message: format::CANCEL_TEXT.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 7: Map result to formatter or error ────────────────────
|
||||
match result {
|
||||
Ok(UserQuestionResponse::Accepted {
|
||||
answers,
|
||||
@@ -550,8 +497,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create resources with a UserQuestionSender injected.
|
||||
/// Returns (shared_resources, rx) where rx receives UserQuestionRequests.
|
||||
fn resources_with_sender() -> (
|
||||
SharedResources,
|
||||
mpsc::UnboundedReceiver<types::UserQuestionRequest>,
|
||||
@@ -562,7 +507,6 @@ mod tests {
|
||||
(resources.into_shared(), rx)
|
||||
}
|
||||
|
||||
/// Like [`resources_with_sender`], with shell-resolved params injected.
|
||||
fn resources_with_sender_and_params(
|
||||
params: AskUserQuestionParams,
|
||||
) -> (
|
||||
@@ -576,8 +520,6 @@ mod tests {
|
||||
(resources.into_shared(), rx)
|
||||
}
|
||||
|
||||
// ── Basic tool metadata tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_name_and_description() {
|
||||
let tool = AskUserQuestionTool;
|
||||
@@ -655,8 +597,6 @@ mod tests {
|
||||
assert_eq!(input.questions[0].multi_select, Some(true));
|
||||
}
|
||||
|
||||
// ── Migration fallback tests (no UserQuestionSender) ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn fallback_ask_single_question() {
|
||||
let resources = Resources::new();
|
||||
@@ -744,8 +684,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation tests ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_duplicate_question_text() {
|
||||
let resources = Resources::new();
|
||||
@@ -770,8 +708,6 @@ mod tests {
|
||||
assert!(msg.contains("Same question?"), "got: {msg}");
|
||||
}
|
||||
|
||||
// ── Blocking round-trip tests ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_round_trip_accepted() {
|
||||
let (shared, mut rx) = resources_with_sender();
|
||||
@@ -848,9 +784,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole questionnaire (multi-question batch) shares one 6-minute timer.
|
||||
/// No `Params` injected — pins the legacy env→default budget for
|
||||
/// consumers that never resolve `[toolset.ask_user_question]`.
|
||||
/// No `Params` injected, which pins the env→default budget for consumers
|
||||
/// that never resolve `[toolset.ask_user_question]`.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn blocking_times_out_after_default_budget_for_batch() {
|
||||
let (shared, mut rx) = resources_with_sender();
|
||||
@@ -878,7 +813,7 @@ mod tests {
|
||||
|
||||
let request = rx.recv().await.expect("should receive request");
|
||||
assert_eq!(request.questions.len(), 2);
|
||||
// Advance past the *effective* budget (honors env override if set).
|
||||
// The effective budget, so the test still passes under an env override.
|
||||
let wait = response_timeout();
|
||||
tokio::time::advance(wait + std::time::Duration::from_secs(1)).await;
|
||||
|
||||
@@ -913,7 +848,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let request = rx.recv().await.expect("should receive request");
|
||||
// Stay well under the effective timeout (env override or default budget).
|
||||
// Stay well under the effective budget, whatever an env override made it.
|
||||
let advance = response_timeout()
|
||||
.checked_div(6)
|
||||
.unwrap_or(std::time::Duration::from_secs(1))
|
||||
@@ -939,14 +874,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configured timeout params tests ──────────────────────────────────
|
||||
|
||||
/// Unset params reproduce the legacy env→default budget; `timeout_enabled
|
||||
/// = false` disarms the timer; `0` never means "wait forever".
|
||||
#[test]
|
||||
fn wait_budget_mapping() {
|
||||
// Compared against `response_timeout()` rather than the raw constant so
|
||||
// the assertions pin the legacy delegation and hold under a dev's env override.
|
||||
// the assertions pin the delegation and hold under a dev's env override.
|
||||
assert_eq!(
|
||||
AskUserQuestionParams::default().wait_budget(),
|
||||
Some(response_timeout()),
|
||||
@@ -973,8 +904,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A short shell-resolved budget fires with the same silent-skip text as
|
||||
/// a user dismiss.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn short_params_timeout_fires_with_cancel_text() {
|
||||
let (shared, mut rx) = resources_with_sender_and_params(AskUserQuestionParams {
|
||||
@@ -1012,8 +941,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeout_enabled = false` waits arbitrarily long — an answer far past
|
||||
/// the default budget still succeeds instead of timing out.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn timeout_disabled_waits_beyond_default_budget() {
|
||||
let (shared, mut rx) = resources_with_sender_and_params(AskUserQuestionParams {
|
||||
@@ -1062,8 +989,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Failure path tests ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_drop_returns_error() {
|
||||
let (shared, mut rx) = resources_with_sender();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! Shared protocol and channel types for the AskUserQuestion blocking flow.
|
||||
//! Shared protocol and channel types for the `AskUserQuestion` blocking flow.
|
||||
//!
|
||||
//! These types define the request/response contract between three crates:
|
||||
//! These types are the request/response contract between three crates, all of
|
||||
//! which import them from `kigi-tools`:
|
||||
//!
|
||||
//! - **`kigi-tools`** — tool blocks on a oneshot, formats the result.
|
||||
//! - **`kigi-shell`** — coordinator receives requests over mpsc, calls the
|
||||
//! client via ACP `ext_method`, sends results back over the oneshot.
|
||||
//! - **`kigi-tui`** — handles the `ExtMethod`, renders UI, returns a
|
||||
//! typed response.
|
||||
//!
|
||||
//! All three crates import these types from `kigi-tools`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -19,13 +18,12 @@ use tokio::sync::{mpsc, oneshot};
|
||||
use super::Question;
|
||||
use crate::register_resource;
|
||||
|
||||
// ── ACP wire-format types ────────────────────────────────────────────────
|
||||
|
||||
/// Annotation on a single question's answer.
|
||||
/// Extra context carried alongside a selected label in the `accepted`
|
||||
/// response.
|
||||
///
|
||||
/// Carried inside the `accepted` response alongside the selected label.
|
||||
/// - `preview`: verbatim `Option.preview` of the selected option (single-select only).
|
||||
/// - `notes`: free-text the user typed in the freeform input.
|
||||
/// - `preview`: verbatim `QuestionOption::preview` of the selected option
|
||||
/// (single-select only).
|
||||
/// - `notes`: free text the user typed into the freeform input.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct QuestionAnnotation {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -34,34 +32,29 @@ pub struct QuestionAnnotation {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Mode context for the question UI.
|
||||
///
|
||||
/// Sent as part of the ACP `ext_method` request so the pager knows whether
|
||||
/// to show plan-mode-only actions (Chat about this / Skip interview).
|
||||
/// Tells the pager which actions to offer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AskUserQuestionMode {
|
||||
/// Normal mode. Client shows only Accept and Cancel.
|
||||
/// Accept and Cancel only.
|
||||
Default,
|
||||
/// Plan mode. Client shows Accept, Cancel, Chat about this, Skip interview.
|
||||
/// Accept, Cancel, Chat about this, Skip interview.
|
||||
Plan,
|
||||
}
|
||||
|
||||
/// ACP `ext_method` request payload (shell coordinator sends to client/pager).
|
||||
///
|
||||
/// Serialized as `camelCase` for the ACP JSON-RPC wire format.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AskUserQuestionExtRequest {
|
||||
pub session_id: String,
|
||||
pub tool_call_id: String,
|
||||
pub questions: Vec<Question>,
|
||||
/// Controls whether the client shows plan-mode-only actions.
|
||||
pub mode: AskUserQuestionMode,
|
||||
}
|
||||
|
||||
/// Accepts both `"value"` (old wire format) and `["value"]` (new wire format)
|
||||
/// for each answer entry, normalizing strings into single-element vectors.
|
||||
/// Accepts an answer entry as either `"value"` or `["value"]`, normalizing the
|
||||
/// bare string into a single-element vector. Clients that predate
|
||||
/// multi-select send the scalar form.
|
||||
fn deserialize_string_or_vec_answers<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<IndexMap<String, Vec<String>>, D::Error>
|
||||
@@ -92,64 +85,54 @@ where
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum AskUserQuestionExtResponse {
|
||||
/// User accepted and submitted answers (Path A).
|
||||
Accepted {
|
||||
/// Answered questions in original order; unanswered omitted.
|
||||
/// One element per selected option; freeform-only is `["Other"]`
|
||||
/// with typed text in `annotations[q].notes`.
|
||||
/// Answered questions in their original order, unanswered ones
|
||||
/// omitted, one element per selected option. A freeform-only answer
|
||||
/// is `["Other"]` with the typed text in `annotations[q].notes`.
|
||||
#[serde(deserialize_with = "deserialize_string_or_vec_answers")]
|
||||
answers: IndexMap<String, Vec<String>>,
|
||||
/// Per-question annotations (preview, notes). Absent when empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<HashMap<String, QuestionAnnotation>>,
|
||||
},
|
||||
/// User chose "Chat about this" (Path B, plan mode only).
|
||||
/// Plan mode only.
|
||||
ChatAboutThis {
|
||||
/// Partial answers: answered questions only, label only (no notes).
|
||||
/// Freeform-only => `"Other"` (notes dropped in plan-mode paths).
|
||||
/// Answered questions only, label only. A freeform-only answer is
|
||||
/// `"Other"` — the plan-mode paths drop the notes.
|
||||
#[serde(default)]
|
||||
partial_answers: HashMap<String, String>,
|
||||
},
|
||||
/// User chose "Skip interview and plan immediately" (Path C, plan mode only).
|
||||
/// Plan mode only.
|
||||
SkipInterview {
|
||||
/// Same partial-answer rules as `ChatAboutThis`.
|
||||
/// Same rules as `ChatAboutThis::partial_answers`.
|
||||
#[serde(default)]
|
||||
partial_answers: HashMap<String, String>,
|
||||
},
|
||||
/// User cancelled / dismissed (Path D). NOT an error.
|
||||
/// User dismissed the questionnaire. NOT an error.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
// ── In-process types (coordinator <-> tool) ──────────────────────────────
|
||||
|
||||
/// In-process result: coordinator -> tool.
|
||||
///
|
||||
/// Uses `Result` so the tool can distinguish user actions from infrastructure
|
||||
/// failures:
|
||||
/// - `Ok(UserQuestionResponse)` for all 4 user paths (accepted, chat, skip, cancel).
|
||||
/// - `Err(UserQuestionError)` for transport failures or malformed responses.
|
||||
/// In-process result, coordinator -> tool. The `Result` separates user actions
|
||||
/// from infrastructure failures — every user path, cancellation included, is
|
||||
/// an `Ok`.
|
||||
pub type UserQuestionResult = Result<UserQuestionResponse, UserQuestionError>;
|
||||
|
||||
/// Successful user response (all 4 user paths).
|
||||
///
|
||||
/// Every variant here produces `Ok(UserAnswered { message })` at the tool
|
||||
/// level with `ToolCall` status `Completed`.
|
||||
/// Every variant produces `Ok(UserAnswered { message })` at the tool level,
|
||||
/// with `ToolCall` status `Completed`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UserQuestionResponse {
|
||||
/// User accepted and submitted answers (Path A).
|
||||
Accepted {
|
||||
/// See `AskUserQuestionExtResponse::Accepted::answers`.
|
||||
answers: IndexMap<String, Vec<String>>,
|
||||
annotations: Option<HashMap<String, QuestionAnnotation>>,
|
||||
},
|
||||
/// User chose "Chat about this" (Path B, plan mode only).
|
||||
/// Carries the original questions so the formatter can iterate all of them.
|
||||
/// Plan mode only. Carries the original questions so the formatter can
|
||||
/// iterate all of them, not just the answered ones.
|
||||
ChatAboutThis {
|
||||
questions: Vec<Question>,
|
||||
partial_answers: HashMap<String, String>,
|
||||
},
|
||||
/// User chose "Skip interview" (Path C, plan mode only).
|
||||
/// Carries the original questions so the formatter can iterate all of them.
|
||||
/// Plan mode only. Carries the original questions so the formatter can
|
||||
/// iterate all of them, not just the answered ones.
|
||||
SkipInterview {
|
||||
questions: Vec<Question>,
|
||||
partial_answers: HashMap<String, String>,
|
||||
@@ -158,10 +141,9 @@ pub enum UserQuestionResponse {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Infrastructure failure (NOT a user action).
|
||||
///
|
||||
/// These produce `Err(ToolError::ExecutionError { .. })` at the tool level
|
||||
/// with `ToolCall` status `Failed`.
|
||||
/// Infrastructure failure, never a user action. These produce
|
||||
/// `Err(ToolError::ExecutionError { .. })` at the tool level, with `ToolCall`
|
||||
/// status `Failed`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UserQuestionError {
|
||||
/// ACP `ext_method` call failed (client disconnect, timeout, etc.).
|
||||
@@ -171,10 +153,9 @@ pub enum UserQuestionError {
|
||||
MalformedResponse(String),
|
||||
}
|
||||
|
||||
/// In-process request: tool -> coordinator (carries oneshot for reply).
|
||||
///
|
||||
/// Sent over the `mpsc` channel. The coordinator receives this, performs the
|
||||
/// ACP `ext_method` round-trip, and sends the result back on `result_tx`.
|
||||
/// In-process request, tool -> coordinator, sent over the `mpsc` channel. The
|
||||
/// coordinator performs the ACP `ext_method` round-trip and sends the result
|
||||
/// back on `result_tx`.
|
||||
#[derive(Educe)]
|
||||
#[educe(Debug)]
|
||||
pub struct UserQuestionRequest {
|
||||
@@ -184,13 +165,8 @@ pub struct UserQuestionRequest {
|
||||
pub result_tx: oneshot::Sender<UserQuestionResult>,
|
||||
}
|
||||
|
||||
// ── Resource type ────────────────────────────────────────────────────────
|
||||
|
||||
/// Resource: `mpsc` sender injected into `SharedResources`.
|
||||
///
|
||||
/// Same injection pattern as `SubagentEventSender`. Cloned into each
|
||||
/// session so that any `AskUserQuestionTool` invocation can emit a
|
||||
/// `UserQuestionRequest` to the session's coordinator.
|
||||
/// Injected into `SharedResources` and cloned into each session, so that any
|
||||
/// `AskUserQuestionTool` invocation can reach that session's coordinator.
|
||||
#[derive(Clone, Educe)]
|
||||
#[educe(Debug)]
|
||||
pub struct UserQuestionSender(
|
||||
@@ -199,15 +175,11 @@ pub struct UserQuestionSender(
|
||||
|
||||
register_resource!("kigi", "UserQuestionSender", UserQuestionSender);
|
||||
|
||||
// ── Conversion helper ────────────────────────────────────────────────────
|
||||
|
||||
impl AskUserQuestionExtResponse {
|
||||
/// Convert the wire-format ACP response into the in-process response type.
|
||||
///
|
||||
/// Called by the shell coordinator after deserializing the client's JSON.
|
||||
/// The `questions` parameter carries the original question list so that
|
||||
/// `ChatAboutThis` and `SkipInterview` responses can iterate all questions
|
||||
/// (answered and unanswered) when formatting the tool result.
|
||||
/// `questions` is the original question list, which `ChatAboutThis` and
|
||||
/// `SkipInterview` need so the formatter can walk answered and unanswered
|
||||
/// questions alike.
|
||||
pub fn into_response(self, questions: Vec<Question>) -> UserQuestionResponse {
|
||||
match self {
|
||||
Self::Accepted {
|
||||
@@ -230,14 +202,10 @@ impl AskUserQuestionExtResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn sample_questions() -> Vec<Question> {
|
||||
vec![
|
||||
Question {
|
||||
@@ -281,8 +249,6 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
// -- AskUserQuestionMode serde --
|
||||
|
||||
#[test]
|
||||
fn mode_serializes_as_snake_case() {
|
||||
assert_eq!(
|
||||
@@ -304,8 +270,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- AskUserQuestionExtRequest serde --
|
||||
|
||||
#[test]
|
||||
fn ext_request_serializes_camel_case() {
|
||||
let req = AskUserQuestionExtRequest {
|
||||
@@ -315,7 +279,6 @@ mod tests {
|
||||
mode: AskUserQuestionMode::Plan,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
// camelCase field names
|
||||
assert!(json.get("sessionId").is_some());
|
||||
assert!(json.get("toolCallId").is_some());
|
||||
assert_eq!(json["mode"], "plan");
|
||||
@@ -337,8 +300,6 @@ mod tests {
|
||||
assert_eq!(back.mode, AskUserQuestionMode::Default);
|
||||
}
|
||||
|
||||
// -- AskUserQuestionExtResponse serde --
|
||||
|
||||
#[test]
|
||||
fn ext_response_accepted_serializes_tagged() {
|
||||
let mut answers = IndexMap::new();
|
||||
@@ -408,7 +369,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ext_response_round_trips_all_variants() {
|
||||
// Accepted
|
||||
let mut answers = IndexMap::new();
|
||||
answers.insert("Q1?".to_string(), vec!["A1".to_string()]);
|
||||
let accepted = AskUserQuestionExtResponse::Accepted {
|
||||
@@ -419,7 +379,6 @@ mod tests {
|
||||
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, AskUserQuestionExtResponse::Accepted { .. }));
|
||||
|
||||
// ChatAboutThis
|
||||
let chat = AskUserQuestionExtResponse::ChatAboutThis {
|
||||
partial_answers: HashMap::new(),
|
||||
};
|
||||
@@ -430,7 +389,6 @@ mod tests {
|
||||
AskUserQuestionExtResponse::ChatAboutThis { .. }
|
||||
));
|
||||
|
||||
// SkipInterview
|
||||
let skip = AskUserQuestionExtResponse::SkipInterview {
|
||||
partial_answers: HashMap::new(),
|
||||
};
|
||||
@@ -441,15 +399,12 @@ mod tests {
|
||||
AskUserQuestionExtResponse::SkipInterview { .. }
|
||||
));
|
||||
|
||||
// Cancelled
|
||||
let cancel = AskUserQuestionExtResponse::Cancelled;
|
||||
let json = serde_json::to_string(&cancel).unwrap();
|
||||
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, AskUserQuestionExtResponse::Cancelled));
|
||||
}
|
||||
|
||||
// -- into_response conversion --
|
||||
|
||||
#[test]
|
||||
fn into_response_accepted() {
|
||||
let mut answers = IndexMap::new();
|
||||
@@ -534,8 +489,6 @@ mod tests {
|
||||
assert!(matches!(resp, UserQuestionResponse::Cancelled));
|
||||
}
|
||||
|
||||
// -- QuestionAnnotation serde --
|
||||
|
||||
#[test]
|
||||
fn annotation_omits_none_fields() {
|
||||
let ann = QuestionAnnotation {
|
||||
@@ -558,8 +511,6 @@ mod tests {
|
||||
assert_eq!(json["notes"], "note");
|
||||
}
|
||||
|
||||
// -- Backwards-compatible deserialization (string -> vec) --
|
||||
|
||||
#[test]
|
||||
fn deserialize_accepted_old_string_format() {
|
||||
let raw = r#"{
|
||||
@@ -594,8 +545,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Deserialization from raw JSON (simulating pager responses) --
|
||||
|
||||
#[test]
|
||||
fn deserialize_accepted_from_raw_json() {
|
||||
let raw = r#"{
|
||||
@@ -656,8 +605,6 @@ mod tests {
|
||||
assert!(matches!(resp, AskUserQuestionExtResponse::Cancelled));
|
||||
}
|
||||
|
||||
// -- IndexMap ordering preservation --
|
||||
|
||||
#[test]
|
||||
fn accepted_answers_preserve_insertion_order() {
|
||||
let mut answers = IndexMap::new();
|
||||
@@ -670,7 +617,6 @@ mod tests {
|
||||
annotations: None,
|
||||
};
|
||||
|
||||
// Round-trip through JSON
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `run_terminal_cmd` (Bash) tool — new architecture (`Tool` trait).
|
||||
//! `run_terminal_cmd` (Bash) tool.
|
||||
//!
|
||||
//! Executes bash commands in a persistent shell session with optional timeout.
|
||||
//! Supports both foreground (blocking) and background (returns task_id) execution.
|
||||
@@ -118,10 +118,6 @@ fn terminal_notification_base(notif: &ToolNotification) -> Option<&BashNotificat
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Params
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the bash tool, stored as `Params<BashParams>` in Resources.
|
||||
///
|
||||
/// All fields are optional — `None` means "use the built-in default".
|
||||
@@ -149,8 +145,8 @@ pub struct BashParams {
|
||||
/// This never bounds background tasks: `timeout: 0` / omitted in background
|
||||
/// mode always resolves to `Duration::MAX` regardless of this value (the
|
||||
/// model owns their lifetime via the background-task tooling; the terminal
|
||||
/// backend's own hard cap is the only backstop). Unset reproduces prior
|
||||
/// behavior with a 5-minute foreground default.
|
||||
/// backend's own hard cap is the only backstop). Unset means a 5-minute
|
||||
/// foreground default.
|
||||
#[serde(default)]
|
||||
pub max_timeout_secs: Option<f64>,
|
||||
/// Max output chars. None → DEFAULT_TOOL_OUTPUT_CHARS (20k).
|
||||
@@ -239,10 +235,6 @@ impl crate::types::resources::ResourceType for BashParams {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Input
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -288,10 +280,6 @@ pub struct BashToolInput {
|
||||
pub is_background: bool,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Output
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The bash tool can produce either a foreground result or a background task handle.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "type")]
|
||||
@@ -320,10 +308,6 @@ impl From<BashToolOutput> for crate::types::output::ToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// DEFAULT prompt formatting
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use crate::util::truncate::format_bytes;
|
||||
|
||||
/// Reason a `run_terminal_cmd` child was terminated before it could exit
|
||||
@@ -442,10 +426,6 @@ pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Default upper bound for model-provided *foreground* command timeouts when
|
||||
// `BashParams.max_timeout_secs` is unset: a transport-safe **5 minutes**.
|
||||
// Consumers that want longer opt in per session via `max_timeout_secs` — in
|
||||
@@ -454,14 +434,14 @@ pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
|
||||
// ceiling; background tasks (`timeout: 0` / omitted in background mode) are
|
||||
// always unbounded regardless of this value — the model owns their lifetime via
|
||||
// the background-task tooling. Absolute safety clamp for configured maxes: 10h.
|
||||
pub(crate) const DEFAULT_MAX_TIMEOUT_MS: u64 = 300_000; // 5 minutes
|
||||
pub(crate) const DEFAULT_MAX_TIMEOUT_MS: u64 = 300_000;
|
||||
const ABSOLUTE_MAX_TIMEOUT_MS: u64 = 36_000_000;
|
||||
/// Default short FG block before auto-bg when auto_background_on_timeout is on.
|
||||
/// Matches terminal `FOREGROUND_BLOCK_BUDGET`.
|
||||
///
|
||||
/// Currently used by tests / `effective_auto_bg_wait_ms` (description follow-up);
|
||||
/// production runtime uses the terminal backend default when budget is unset.
|
||||
#[allow(dead_code)] // description follow-up + tests (not yet model-facing)
|
||||
/// Only tests and `effective_auto_bg_wait_ms` read it; the production runtime
|
||||
/// uses the terminal backend default when the budget is unset.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const DEFAULT_FOREGROUND_BLOCK_BUDGET_MS: u64 = 15_000;
|
||||
|
||||
/// Internal version discriminant for run_terminal_cmd.
|
||||
@@ -489,10 +469,6 @@ impl BashVersion {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Background operator detection
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Detects only a trailing `&` (after trimming), excluding `&&` and `>&`.
|
||||
///
|
||||
/// Used where only a trailing `&` backgrounds a command: the legacy-0.4.10 bash
|
||||
@@ -643,7 +619,8 @@ fn ends_with_wait_builtin(command: &str) -> bool {
|
||||
/// The heredoc *body* is consumed separately by `skip_heredoc_body`.
|
||||
fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, usize)> {
|
||||
let len = chars.len();
|
||||
let mut i = start + 2; // skip `<<`
|
||||
// Skip `<<`.
|
||||
let mut i = start + 2;
|
||||
|
||||
// `<<-` strips leading tabs from the body and the closing delimiter.
|
||||
let strip_tabs = i < len && chars[i] == '-';
|
||||
@@ -656,8 +633,9 @@ fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, us
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// No delimiter word.
|
||||
if i >= len || chars[i] == '\n' {
|
||||
return None; // no delimiter
|
||||
return None;
|
||||
}
|
||||
|
||||
let delimiter: String;
|
||||
@@ -668,11 +646,13 @@ fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, us
|
||||
while i < len && chars[i] != '\'' {
|
||||
i += 1;
|
||||
}
|
||||
// Unclosed quote.
|
||||
if i >= len {
|
||||
return None; // unclosed quote
|
||||
return None;
|
||||
}
|
||||
delimiter = chars[d_start..i].iter().collect();
|
||||
i += 1; // skip closing quote
|
||||
// Skip the closing quote.
|
||||
i += 1;
|
||||
} else if chars[i] == '"' {
|
||||
// Double-quoted delimiter: << "WORD"
|
||||
i += 1;
|
||||
@@ -732,14 +712,15 @@ fn skip_heredoc_body(chars: &[char], start: usize, delimiter: &str, strip_tabs:
|
||||
};
|
||||
|
||||
if check == delimiter {
|
||||
// Skip the `\n` after the delimiter.
|
||||
if i < len {
|
||||
i += 1; // skip the `\n` after the delimiter
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
if i < len {
|
||||
i += 1; // skip `\n`
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,13 +741,12 @@ fn contains_background_operator(command: &str) -> bool {
|
||||
while i < len {
|
||||
let ch = chars[i];
|
||||
|
||||
// ── Backslash escape (honoured everywhere except inside single quotes) ──
|
||||
// A backslash escape is honoured everywhere except inside single quotes.
|
||||
if ch == '\\' && !in_single_quote {
|
||||
i += 2; // skip the escaped character
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Quote tracking ──
|
||||
if ch == '\'' && !in_double_quote {
|
||||
in_single_quote = !in_single_quote;
|
||||
i += 1;
|
||||
@@ -780,7 +760,7 @@ fn contains_background_operator(command: &str) -> bool {
|
||||
|
||||
// Newline: if heredocs are pending, skip their bodies.
|
||||
if ch == '\n' && !in_single_quote && !in_double_quote && !pending_heredocs.is_empty() {
|
||||
i += 1; // skip the newline
|
||||
i += 1;
|
||||
for (delim, strip_tabs) in pending_heredocs.drain(..) {
|
||||
i = skip_heredoc_body(&chars, i, &delim, strip_tabs);
|
||||
}
|
||||
@@ -837,10 +817,6 @@ fn contains_background_operator(command: &str) -> bool {
|
||||
|
||||
false
|
||||
}
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Self-matching pkill/pgrep detection
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Matches a `pkill` / `pgrep` invocation at a command-word position with a
|
||||
/// `-f`-bearing flag bundle (short cluster `-X*fX*` or long `--full`) and
|
||||
/// captures the command word plus the first positional argument (the
|
||||
@@ -958,14 +934,14 @@ fn self_matching_pkill_pattern(command: &str) -> Option<SelfMatchingPkill> {
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(86400); // 24 hours
|
||||
const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(86400);
|
||||
|
||||
/// Max time a *non-backgroundable* foreground command may block the turn. Such
|
||||
/// a command has only its requested `timeout` (up to 10h), so a long timeout
|
||||
/// would wedge the turn; we clamp and kill at this cap instead. Backgroundable
|
||||
/// commands use the terminal's `FOREGROUND_BLOCK_BUDGET` instead. Long work
|
||||
/// should use `background: true`. Env override: `KIGI_MAX_FOREGROUND_BLOCK_MS`.
|
||||
const MAX_FOREGROUND_BLOCK: Duration = Duration::from_secs(300); // 5 minutes
|
||||
const MAX_FOREGROUND_BLOCK: Duration = Duration::from_secs(300);
|
||||
|
||||
fn max_foreground_block() -> Duration {
|
||||
std::env::var("KIGI_MAX_FOREGROUND_BLOCK_MS")
|
||||
@@ -985,10 +961,6 @@ fn clamp_foreground_block(
|
||||
timeout.min(max_block.max(config_timeout))
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Bare `echo "<msg>"` detection (for statistics + hints in kigi bash)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tracks usage of bare `echo "<msg>"` (and close variants) inside the bash tool.
|
||||
///
|
||||
/// These are often used by the model as a substitute for direct output or
|
||||
@@ -999,16 +971,6 @@ struct BareEchoHintState {
|
||||
call_count: usize,
|
||||
}
|
||||
|
||||
/// Returns true for simple "bare echo" commands whose primary purpose appears
|
||||
/// to be emitting a short literal message (as opposed to scripting, logging
|
||||
/// with complex formatting, or part of a larger pipeline).
|
||||
///
|
||||
/// Heuristics (conservative to start):
|
||||
/// - Starts with `echo` (possibly with output-control flags -n/-e/-E).
|
||||
/// - Followed by what looks like a single message token (quoted or bare).
|
||||
/// - No shell metacharacters indicating chaining, redirection, substitution,
|
||||
/// or complex post-processing after the message.
|
||||
///
|
||||
/// Check whether `rest` (the portion after the command name and any flags)
|
||||
/// is a simple narration message with no shell metacharacters.
|
||||
///
|
||||
@@ -1075,7 +1037,6 @@ fn is_bare_printf(command: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
let rest = after_prefix.trim_start();
|
||||
// printf -v var ... is variable assignment, not narration.
|
||||
if rest.starts_with("-v") || rest.starts_with("--") {
|
||||
return false;
|
||||
}
|
||||
@@ -1136,8 +1097,6 @@ mod bare_echo_tests {
|
||||
assert!(!is_bare_echo("echotool"));
|
||||
}
|
||||
|
||||
// ── is_bare_printf tests ──
|
||||
|
||||
#[test]
|
||||
fn printf_simple() {
|
||||
assert!(is_bare_printf(r#"printf "hello\n""#));
|
||||
@@ -1172,12 +1131,6 @@ mod bare_echo_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool implementation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bash tool — new architecture.
|
||||
///
|
||||
/// Executes bash commands via the `Terminal` backend. Supports foreground
|
||||
/// (blocking) and background (returns task_id) execution modes.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -1321,8 +1274,8 @@ impl BashTool {
|
||||
/// backend's documented default (15s) for this helper — the real process
|
||||
/// still honors `KIGI_FOREGROUND_BLOCK_BUDGET_MS` via `None` on the request.
|
||||
///
|
||||
/// Not yet used in model-facing descriptions (historical auto-bg copy only).
|
||||
#[allow(dead_code)] // description follow-up + unit tests
|
||||
/// Not used in model-facing descriptions; only unit tests read it.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn effective_auto_bg_wait_ms(params: &BashParams) -> Option<u64> {
|
||||
if !Self::auto_background_on_timeout_enabled(params) {
|
||||
return None;
|
||||
@@ -1356,9 +1309,7 @@ impl BashTool {
|
||||
// `timeout: 0` is always unbounded, so this note is
|
||||
// unconditional.
|
||||
let bg_zero = "`timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.";
|
||||
// Keep main-style auto-bg wording (no FG-budget ms advertised).
|
||||
// Follow-up: surface effective_auto_bg_wait_ms / FG budget here
|
||||
// once we deliberately change model-facing copy.
|
||||
// Auto-bg wording does not advertise the FG block budget ms.
|
||||
let desc = if auto_bg {
|
||||
format!(
|
||||
"Optional timeout in milliseconds (max {max_ms}). Default: {default_ms}. If not specified, commands exceeding the default timeout will be automatically backgrounded. {bg_zero}"
|
||||
@@ -1394,8 +1345,8 @@ impl BashTool {
|
||||
Some(desc) => desc,
|
||||
None => Self::default_description_template(background_enabled),
|
||||
};
|
||||
// Template only interpolates max/default timeout numbers + auto_bg flag.
|
||||
// Do not advertise FG block budget ms here yet (follow-up PR).
|
||||
// Template only interpolates max/default timeout numbers + auto_bg flag,
|
||||
// never the FG block budget ms.
|
||||
let extras = serde_json::json!({
|
||||
"auto_background_on_timeout": auto_bg,
|
||||
"max_timeout_ms": Self::effective_max_timeout_ms(params),
|
||||
@@ -1419,9 +1370,8 @@ impl BashTool {
|
||||
}
|
||||
|
||||
fn default_description_template_enabled() -> &'static str {
|
||||
// NOTE: auto-bg wording is intentionally the historical main copy (no
|
||||
// FG-block-budget ms). Runtime auto-bg uses min(timeout, FG budget);
|
||||
// advertising that wait is a separate description PR.
|
||||
// The auto-bg wording intentionally omits the FG block budget ms, even
|
||||
// though runtime auto-bg waits min(timeout, FG budget).
|
||||
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
|
||||
|
||||
Usage notes:
|
||||
@@ -1788,7 +1738,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
|
||||
let tool_call_id = ctx.call_id.clone();
|
||||
|
||||
// --- Read resources ---
|
||||
let (backend, session_folder, env, notification_handle, owner_session_id) = {
|
||||
let res = resources.lock().await;
|
||||
(
|
||||
@@ -1809,7 +1758,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
None => notification_handle,
|
||||
};
|
||||
|
||||
// Read params
|
||||
let params = resources
|
||||
.lock()
|
||||
.await
|
||||
@@ -1824,7 +1772,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
.output_byte_limit
|
||||
.unwrap_or(DEFAULT_TOOL_OUTPUT_CHARS);
|
||||
|
||||
// Use truncation config override if available
|
||||
let output_byte_limit = resources
|
||||
.lock()
|
||||
.await
|
||||
@@ -1835,7 +1782,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
})
|
||||
.unwrap_or(config_output_byte_limit);
|
||||
|
||||
// --- Validate: reject commands that use `&` as a background operator ---
|
||||
let version = BashVersion::from_contract(
|
||||
crate::types::tool_metadata::behavior_version(&ctx).as_deref(),
|
||||
);
|
||||
@@ -1878,7 +1824,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
return Err(kigi_tool_runtime::ToolError::invalid_arguments(message));
|
||||
}
|
||||
|
||||
// --- Validate: reject self-matching pkill/pgrep -f <pat> ---
|
||||
// `pkill -f` matches the wrapper bash's full argv (which contains
|
||||
// the literal pattern), causing the wrapper to SIGTERM itself
|
||||
// before the rest of the script runs. Reject obvious cases at
|
||||
@@ -1904,21 +1849,18 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
));
|
||||
}
|
||||
|
||||
// --- Prefix ---
|
||||
let command = Self::get_prefixed_command(¶ms.cmd_prefix, &input.command);
|
||||
|
||||
// No command-wrapping is performed here today; `display_command` is
|
||||
// No command-wrapping is performed here; `display_command` is
|
||||
// populated only by tools that intentionally surface a friendlier form
|
||||
// to the model (e.g. the monitor tool). Bash commands run as-is.
|
||||
let display_command: Option<String> = None;
|
||||
|
||||
// --- Route to foreground or background ---
|
||||
let output_file = session_folder
|
||||
.join("terminal")
|
||||
.join(format!("{}.log", tool_call_id.as_str()));
|
||||
|
||||
if input.is_background {
|
||||
// ─── Background execution ───
|
||||
// Force unbuffered Python stdout so output reaches files and
|
||||
// pipes in real time rather than buffering ~8 KB.
|
||||
let mut env = env;
|
||||
@@ -1969,7 +1911,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
let bg_output_file = handle.output_file;
|
||||
let bg_pid = handle.pid;
|
||||
|
||||
// Send backgrounded notification
|
||||
let base = BashNotificationBase {
|
||||
tool_call_id: tool_call_id.as_str().to_owned(),
|
||||
command: input.command.clone(),
|
||||
@@ -2010,7 +1951,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
pid: bg_pid,
|
||||
}))
|
||||
} else {
|
||||
// ─── Foreground execution ───
|
||||
let timeout = Self::resolve_effective_timeout_for_params(
|
||||
input.timeout,
|
||||
false,
|
||||
@@ -2037,18 +1977,11 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
notification_handle: notification_handle.clone(),
|
||||
tool_call_id: tool_call_id.as_str().to_owned(),
|
||||
display_command,
|
||||
// Honour `auto_background_on_timeout` regardless of
|
||||
// whether the model supplied an explicit `timeout`.
|
||||
// The previous gate (`input.timeout.is_none()`) hard-
|
||||
// timed out commands with explicit timeouts even when
|
||||
// the session had opted into auto-bg behavior.
|
||||
// The relaxed semantics matter here:
|
||||
// every Shell call carries an explicit `block_until_ms`
|
||||
// and the harness's observed behavior is to auto-background
|
||||
// past that deadline rather than kill the process.
|
||||
// Backwards compatible because
|
||||
// `auto_background_on_timeout` defaults to `false`;
|
||||
// existing kigi callers that never opted in are
|
||||
// Honour `auto_background_on_timeout` even when the model
|
||||
// supplied an explicit `timeout`: every Shell call carries an
|
||||
// explicit `block_until_ms`, and the harness auto-backgrounds
|
||||
// past that deadline rather than killing the process. The flag
|
||||
// defaults to `false`, so callers that never opt in are
|
||||
// unaffected.
|
||||
auto_background_on_timeout: Self::auto_background_on_timeout_enabled(¶ms),
|
||||
foreground_block_budget: Self::effective_foreground_block_budget(¶ms),
|
||||
@@ -2070,7 +2003,7 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Backgrounded (user Ctrl+G or auto-timeout): return BackgroundTaskStarted ───
|
||||
// Backgrounded (user Ctrl+G or auto-timeout): return BackgroundTaskStarted.
|
||||
let auto_backgrounded = result.signal.as_deref() == Some("auto_backgrounded");
|
||||
if auto_backgrounded || result.signal.as_deref() == Some("backgrounded") {
|
||||
let base = BashNotificationBase {
|
||||
@@ -2131,7 +2064,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
}));
|
||||
}
|
||||
|
||||
// Send completion notification
|
||||
let base = BashNotificationBase {
|
||||
tool_call_id: tool_call_id.as_str().to_owned(),
|
||||
command: input.command.clone(),
|
||||
@@ -2172,9 +2104,8 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
};
|
||||
bash.output_for_prompt = format_default_prompt(&bash);
|
||||
|
||||
// Bare `echo "<msg>"` usage (common model anti-pattern for "just output something").
|
||||
// We tag it for statistics (kigi backend) and can surface an educational
|
||||
// hint on repeated use.
|
||||
// Bare `echo "<msg>"` is a common model anti-pattern for "just
|
||||
// output something"; tag it for statistics and repeated-use hints.
|
||||
bash.was_bare_echo = is_bare_echo(&bash.command) || is_bare_printf(&bash.command);
|
||||
if bash.was_bare_echo {
|
||||
let mut res = resources.lock().await;
|
||||
@@ -2208,10 +2139,6 @@ impl kigi_tool_runtime::Tool for BashTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2296,7 +2223,8 @@ mod tests {
|
||||
// requested `timeout`.
|
||||
#[test]
|
||||
fn foreground_block_clamps_explicit_long_timeout() {
|
||||
let cfg = DEFAULT_TIMEOUT; // 120s session default
|
||||
// 120s session default.
|
||||
let cfg = DEFAULT_TIMEOUT;
|
||||
let cap = MAX_FOREGROUND_BLOCK;
|
||||
// 10h explicit timeout → clamped to the cap.
|
||||
assert_eq!(
|
||||
@@ -2316,8 +2244,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mock terminal ───
|
||||
|
||||
/// Configurable mock terminal backend for testing.
|
||||
///
|
||||
/// Stores foreground results as clonable `TerminalRunResult`, and
|
||||
@@ -2457,8 +2383,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test helpers ───
|
||||
|
||||
fn make_resources(mock: MockTerminal) -> Resources {
|
||||
make_resources_with_params(mock, BashParams::default())
|
||||
}
|
||||
@@ -2473,7 +2397,6 @@ mod tests {
|
||||
resources.insert(NotificationHandle(ToolNotificationHandle::noop()));
|
||||
resources.insert(Params(params));
|
||||
|
||||
// Add TemplateRenderer with default mappings for background hint and validation
|
||||
let execute_params =
|
||||
HashMap::from([("is_background".to_string(), "is_background".to_string())]);
|
||||
let bg_params = HashMap::from([("task_id".to_string(), "task_id".to_string())]);
|
||||
@@ -2521,8 +2444,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Streaming (BashTool::execute) test scaffolding ───
|
||||
//
|
||||
// `test_ctx` stamps `WorkspaceViewerContext { stream_tool_progress:
|
||||
// true }` by default, so these tests exercise the streaming path.
|
||||
|
||||
@@ -2579,8 +2500,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Streaming tests ───
|
||||
|
||||
/// Deterministic regression guard for Key Decision 6 (delta math keyed off
|
||||
/// the monotonic `total_bytes`, not buffer length). Exercises the common
|
||||
/// suffix-slice case, the no-new-bytes case, the post-truncation shrinking
|
||||
@@ -2636,7 +2555,8 @@ mod tests {
|
||||
fn bash_output_chunk_progress_caps_oversized_delta() {
|
||||
// Multi-byte chars (`€` = 3 bytes) ensure the cap lands mid-char so we
|
||||
// exercise the char-boundary back-off, not just a clean byte cut.
|
||||
let payload_str = "€".repeat(7_000); // 21_000 bytes, well over the cap.
|
||||
// 21_000 bytes, well over the cap.
|
||||
let payload_str = "€".repeat(7_000);
|
||||
let total = payload_str.len();
|
||||
assert!(total > MAX_PROGRESS_DELTA_BYTES);
|
||||
let chunk = BashOutputChunk {
|
||||
@@ -2966,8 +2886,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreground_command_success() {
|
||||
let resources = make_resources(MockTerminal::success("hello world\n", 0));
|
||||
@@ -3010,7 +2928,8 @@ mod tests {
|
||||
match result {
|
||||
BashToolOutput::Foreground(bash) => {
|
||||
assert!(bash.timed_out);
|
||||
assert_eq!(bash.exit_code, -1); // no exit code when timed out
|
||||
// No exit code when timed out.
|
||||
assert_eq!(bash.exit_code, -1);
|
||||
}
|
||||
BashToolOutput::Background(_) => panic!("Expected foreground output"),
|
||||
}
|
||||
@@ -3110,8 +3029,7 @@ mod tests {
|
||||
let resources = make_resources_reject_bg_op(MockTerminal::success("", 0));
|
||||
let tool = BashTool;
|
||||
|
||||
// Previously this would pass because the old check only looked at
|
||||
// trailing `&`. Now the parser detects mid-command `&` too.
|
||||
// The parser detects a mid-command `&`, not just a trailing one.
|
||||
let result = kigi_tool_runtime::Tool::run(
|
||||
&tool,
|
||||
test_ctx(resources.into_shared()),
|
||||
@@ -3202,9 +3120,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Enabled-background `&` rejection must name the real param resolved from
|
||||
/// the template (`is_background`), never a blank "set =true". Regression: the
|
||||
/// template previously used the non-existent `params.execute.background` key,
|
||||
/// which resolved to "".
|
||||
/// the template (`is_background`), never a blank "set =true". Regression
|
||||
/// guard: the non-existent `params.execute.background` key resolves to "".
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn background_operator_rejection_names_is_background_param() {
|
||||
@@ -3229,7 +3146,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_prefix_prepended() {
|
||||
// We can test this via the static helper
|
||||
assert_eq!(
|
||||
BashTool::get_prefixed_command(&Some("source ~/.bashrc".to_string()), "ls"),
|
||||
"source ~/.bashrc && ls"
|
||||
@@ -3297,10 +3213,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_default_prompt tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_bash_output(exit_code: i32, output: &str) -> BashOutput {
|
||||
let mut bash = BashOutput {
|
||||
output: output.as_bytes().to_vec(),
|
||||
@@ -3456,16 +3368,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── contains_background_operator unit tests ───
|
||||
|
||||
mod background_operator_tests {
|
||||
use super::super::{
|
||||
command_has_bash_background_operator, contains_background_operator,
|
||||
contains_unwaited_background_operator,
|
||||
};
|
||||
|
||||
// ── Should detect (true) ──
|
||||
|
||||
#[test]
|
||||
fn trailing_ampersand() {
|
||||
assert!(contains_background_operator("sleep 600 &"));
|
||||
@@ -3527,8 +3435,6 @@ mod tests {
|
||||
assert!(contains_background_operator("cmd > out.txt &"));
|
||||
}
|
||||
|
||||
// ── Should NOT detect (false) ──
|
||||
|
||||
#[test]
|
||||
fn no_ampersand() {
|
||||
assert!(!contains_background_operator("echo hello"));
|
||||
@@ -3616,8 +3522,6 @@ mod tests {
|
||||
assert!(!contains_background_operator("\"\\&\""));
|
||||
}
|
||||
|
||||
// ── Mixed cases ──
|
||||
|
||||
#[test]
|
||||
fn logical_and_then_background() {
|
||||
// `echo a && sleep 10 &` — the trailing `&` IS a background op.
|
||||
@@ -3636,8 +3540,6 @@ mod tests {
|
||||
assert!(contains_background_operator("echo '&' & echo \"&\""));
|
||||
}
|
||||
|
||||
// ── contains_unwaited_background_operator (combined check) ──
|
||||
|
||||
#[test]
|
||||
fn unwaited_trailing_ampersand_rejected() {
|
||||
assert!(contains_unwaited_background_operator("sleep 600 &"));
|
||||
@@ -3700,7 +3602,7 @@ mod tests {
|
||||
assert!(contains_unwaited_background_operator("sleep 600 & await"));
|
||||
}
|
||||
|
||||
// ── Heredoc: `&` inside heredoc bodies must not be detected ──
|
||||
// A `&` inside a heredoc body must not be detected.
|
||||
|
||||
#[test]
|
||||
fn heredoc_single_quoted_delimiter() {
|
||||
@@ -3819,8 +3721,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── resolve_effective_timeout unit tests ───
|
||||
|
||||
mod resolve_effective_timeout_tests {
|
||||
use super::super::{BashParams, BashTool, DEFAULT_MAX_TIMEOUT_MS, DEFAULT_TIMEOUT};
|
||||
use std::time::Duration;
|
||||
@@ -3878,7 +3778,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
BashTool::resolve_effective_timeout_for_params(
|
||||
Some(20 * 60 * 1_000),
|
||||
true, // background
|
||||
// background
|
||||
true,
|
||||
super::super::BACKGROUND_TIMEOUT,
|
||||
¶ms,
|
||||
),
|
||||
@@ -3982,8 +3883,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FG block budget + schema description unit tests ───
|
||||
|
||||
mod foreground_block_budget_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
@@ -4172,8 +4071,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── self_matching_pkill_pattern unit tests ───
|
||||
|
||||
mod self_matching_pkill_tests {
|
||||
use super::super::self_matching_pkill_pattern;
|
||||
|
||||
@@ -4181,8 +4078,6 @@ mod tests {
|
||||
self_matching_pkill_pattern(command).map(|h| (h.cmd, h.pattern))
|
||||
}
|
||||
|
||||
// ── Should detect (Some) ──
|
||||
|
||||
#[test]
|
||||
fn pkill_then_run_self() {
|
||||
assert_eq!(
|
||||
@@ -4231,8 +4126,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Should NOT detect (None) ──
|
||||
|
||||
#[test]
|
||||
fn pkill_alone_no_later_reference() {
|
||||
assert!(self_matching_pkill_pattern("pkill -f ./clavitor-web").is_none());
|
||||
@@ -4303,8 +4196,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy trailing-only `&` detection ───
|
||||
|
||||
mod legacy_trailing_ampersand {
|
||||
use super::*;
|
||||
|
||||
@@ -4345,8 +4236,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PowerShell `&` detection + remediation ───
|
||||
|
||||
mod powershell_background_check {
|
||||
use super::*;
|
||||
|
||||
@@ -4400,8 +4289,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shell-aware gate decision (pure, all platforms) ───
|
||||
|
||||
mod gate_decision {
|
||||
use super::*;
|
||||
|
||||
@@ -4526,8 +4413,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Versioned `&` detection integration ───
|
||||
|
||||
mod versioned_background_check {
|
||||
use super::*;
|
||||
|
||||
@@ -4621,8 +4506,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Description template shell-awareness tests ───
|
||||
//
|
||||
// Exercises the `${%- if has_unix_utilities %}` branch — fix for
|
||||
// `'grep' is not recognized` on PowerShell / cmd.exe. We can't
|
||||
// toggle the host shell from a unit test, so we render the template
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Stub surface when the deploy feature is off.
|
||||
|
||||
/// Placeholder config — deploy is unavailable in this build.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum AppBuilderDeployerConfig {
|
||||
#[default]
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
//! `EnterPlanMode` tool — new architecture (`Tool` trait).
|
||||
//!
|
||||
//! Gateway tool that the agent calls when it decides a task is complex enough
|
||||
//! to warrant a planning phase before writing code. This is the
|
||||
//! **agent-initiated** entry path into plan mode.
|
||||
//! `EnterPlanMode` tool: the agent-initiated entry path into plan mode.
|
||||
//!
|
||||
//! On success it notifies orchestration (`PlanModeEntered`) and seeds an empty
|
||||
//! session plan file if missing (never truncating existing content), so the
|
||||
//! model can read it before writing. Read-only enforcement and plan-file gating
|
||||
//! stay in orchestration.
|
||||
//!
|
||||
//! ## User Consent
|
||||
//!
|
||||
//! This tool requires user approval before executing. The UI should present a
|
||||
//! confirmation dialog. If the user declines, the tool result is rejected and
|
||||
//! the model receives `"User declined to enter plan mode."`.
|
||||
//! Execution requires user approval: the UI presents a confirmation dialog, and
|
||||
//! on decline the tool result is rejected and the model receives
|
||||
//! `"User declined to enter plan mode."`.
|
||||
|
||||
use crate::computer::types::AsyncFileSystem;
|
||||
use crate::notification::types::PlanModeEntered;
|
||||
@@ -27,18 +21,12 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Input for the `EnterPlanMode` tool.
|
||||
///
|
||||
/// Empty object — no parameters. The decision to enter plan mode is a binary
|
||||
/// gate. All configuration (workflow variant, explore agent count, etc.) comes
|
||||
/// from feature flags and environment variables, not from the tool call.
|
||||
/// Deliberately empty: entering plan mode is a binary gate, and every knob
|
||||
/// (workflow variant, explore agent count, etc.) comes from feature flags and
|
||||
/// environment variables rather than the tool call.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct EnterPlanModeInput {}
|
||||
|
||||
/// `EnterPlanMode` tool: signals plan mode entry and seeds the session plan
|
||||
/// file, returning a [`PlanFileSeedStatus`].
|
||||
///
|
||||
/// Params: `()` — no per-tool configuration.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EnterPlanModeTool;
|
||||
|
||||
@@ -111,7 +99,6 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
|
||||
let (seed_target, plan_file_path, tool_hints, fs) = {
|
||||
let res = resources.lock().await;
|
||||
|
||||
// Send notification first.
|
||||
if let Some(handle) = res.get::<NotificationHandle>() {
|
||||
handle.0.send_plan_mode_entered(PlanModeEntered {
|
||||
tool_call_id: ctx.call_id.as_str().to_owned(),
|
||||
@@ -120,7 +107,6 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
|
||||
|
||||
let (seed_target, plan_file_path) = resolve_plan_file_path(&res);
|
||||
|
||||
// Resolve client-facing tool names via TemplateRenderer.
|
||||
let hints = if let Some(renderer) = res.get::<TemplateRenderer>() {
|
||||
EnterPlanModeToolHints {
|
||||
ask_user: renderer
|
||||
@@ -171,10 +157,8 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the plan file; create an empty one only on not-found.
|
||||
///
|
||||
/// Never truncates existing content. Non-NotFound read errors fail closed as
|
||||
/// [`PlanFileSeedStatus::Missing`] without calling `write_file`.
|
||||
/// Never truncates existing content: a read error other than `NotFound` fails
|
||||
/// closed as [`PlanFileSeedStatus::Missing`] without calling `write_file`.
|
||||
async fn probe_or_create_empty_plan_file(
|
||||
fs: &dyn AsyncFileSystem,
|
||||
path: &Path,
|
||||
@@ -196,8 +180,8 @@ async fn probe_or_create_empty_plan_file(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-NotFound read error: a directory at the path reads as IsADirectory;
|
||||
// anything else is treated as inaccessible. Never write (avoid truncate risk).
|
||||
// The path may hold real content we simply could not read, so
|
||||
// report the failure instead of writing over it.
|
||||
let reason = match e.io_error_kind() {
|
||||
Some(std::io::ErrorKind::IsADirectory) => PlanFileSeedFailure::NotAFile,
|
||||
_ => PlanFileSeedFailure::Inaccessible,
|
||||
@@ -234,8 +218,8 @@ mod tests {
|
||||
(resources, plan)
|
||||
}
|
||||
|
||||
/// Parametrized FS mock: injects the read/write outcomes and counts calls
|
||||
/// so a test can assert the tool never touched the FS.
|
||||
/// Injects fixed read/write outcomes and counts calls, so a test can assert
|
||||
/// the tool never touched the FS.
|
||||
struct ProbeMockFs {
|
||||
read: Result<Vec<u8>, ComputerError>,
|
||||
write: Result<(), ComputerError>,
|
||||
@@ -587,8 +571,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- PlanFilePath resource tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_plan_file_path_resource_when_set() {
|
||||
let mut resources = Resources::new();
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
//! `ExitPlanMode` tool — new architecture (`Tool` trait).
|
||||
//! `ExitPlanMode` tool: signals that the agent has finished planning.
|
||||
//!
|
||||
//! Signals that the agent has finished planning and is ready for the user to
|
||||
//! review and approve the plan. The tool reads the plan file from disk (it does
|
||||
//! NOT accept plan content as input) and surfaces it via:
|
||||
//!
|
||||
//! 1. A `PlanModeExited` **notification** sent to the gateway/client, carrying
|
||||
//! the plan content so the client can present it for user approval.
|
||||
//! 2. A structured **`ExitPlanModeOutput`** returned to the model, containing
|
||||
//! the plan content (or an empty-plan message).
|
||||
//!
|
||||
//! The actual approval flow (yes/no with feedback, context clear, mode
|
||||
//! transition) happens on the client side — this tool just says "I'm done,
|
||||
//! here's the plan."
|
||||
//!
|
||||
//! ## Plan File
|
||||
//!
|
||||
//! The plan file path defaults to `.kigi/plan.md` relative to the session
|
||||
//! `Cwd`. The tool reads it via the `FileSystem` resource (the same async FS
|
||||
//! abstraction used by `ReadFile` and `SearchReplace`).
|
||||
//! The plan is read from disk (path defaults to `.kigi/plan.md` relative to the
|
||||
//! session `Cwd`) and surfaced both as a `PlanModeExited` notification and as
|
||||
//! the structured output returned to the model. The approval flow itself
|
||||
//! (yes/no with feedback, context clear, mode transition) lives on the client.
|
||||
|
||||
pub mod types;
|
||||
|
||||
@@ -29,23 +15,12 @@ use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::resources::{FileSystem, NotificationHandle, require_plan_file_path};
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
/// Input for the `ExitPlanMode` tool.
|
||||
///
|
||||
/// Empty object — the plan is read from the plan file on disk, NOT passed as
|
||||
/// a parameter. This ensures the user sees exactly what was written to disk,
|
||||
/// preventing divergence between the model's in-context plan and the actual
|
||||
/// file content.
|
||||
/// Deliberately empty: the plan is read from disk rather than passed in, so the
|
||||
/// user sees exactly what was written and the model's in-context plan cannot
|
||||
/// diverge from the file.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct ExitPlanModeInput {}
|
||||
|
||||
/// `ExitPlanMode` tool.
|
||||
///
|
||||
/// Reads the plan file from disk and signals to the orchestration layer that
|
||||
/// the agent is done planning. The client receives a `PlanModeExited`
|
||||
/// notification with the plan content and is responsible for presenting the
|
||||
/// approval UI.
|
||||
///
|
||||
/// Params: `()` — no per-tool configuration.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExitPlanModeTool;
|
||||
|
||||
@@ -123,7 +98,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
|
||||
|
||||
let (plan_path, plan_file_path_display) = require_plan_file_path(&res)?;
|
||||
|
||||
// Read the plan file from disk via the FileSystem abstraction.
|
||||
let content = if let Some(fs) = res.get::<FileSystem>() {
|
||||
match fs.0.read_file(&plan_path).await {
|
||||
Ok(bytes) => {
|
||||
@@ -137,7 +111,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
// Fallback: try tokio::fs if no FileSystem resource is available.
|
||||
match tokio::fs::read_to_string(&plan_path).await {
|
||||
Ok(text) if !text.trim().is_empty() => Some(text),
|
||||
_ => None,
|
||||
@@ -147,7 +120,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
|
||||
(plan_file_path_display, content)
|
||||
};
|
||||
|
||||
// Notify the gateway / client.
|
||||
{
|
||||
let res = resources.lock().await;
|
||||
if let Some(handle) = res.get::<NotificationHandle>() {
|
||||
@@ -263,7 +235,6 @@ mod tests {
|
||||
assert!(message.contains("start coding"));
|
||||
assert!(plan_content.contains("Do thing A"));
|
||||
assert!(plan_content.contains("Do thing B"));
|
||||
// Cwd fallback now displays the resolved absolute path (shared resolver).
|
||||
assert!(plan_file_path.ends_with(".kigi/plan.md"));
|
||||
}
|
||||
other => panic!("Expected PlanReady, got {:?}", other),
|
||||
@@ -405,8 +376,6 @@ mod tests {
|
||||
assert!(prompt.contains("## Plan:"));
|
||||
}
|
||||
|
||||
// -- PlanFilePath resource tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_from_plan_file_path_resource() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
//! Shared between the shell (serializer) and the pager/desktop/VS Code
|
||||
//! (deserializer) so both sides stay in sync.
|
||||
|
||||
/// ACP `ext_method` request payload (shell coordinator sends to client/pager).
|
||||
///
|
||||
/// Serialized as `camelCase` for the ACP JSON-RPC wire format.
|
||||
/// Request payload the shell coordinator sends to the client/pager.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExitPlanModeExtRequest {
|
||||
@@ -14,7 +12,7 @@ pub struct ExitPlanModeExtRequest {
|
||||
pub plan_content: Option<String>,
|
||||
}
|
||||
|
||||
/// ACP `ext_method` response payload (client/pager returns to shell coordinator).
|
||||
/// Response payload the client/pager returns to the shell coordinator.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExitPlanModeExtResponse {
|
||||
/// `"approved"`, `"cancelled"`, or `"abandoned"`.
|
||||
@@ -39,7 +37,6 @@ mod tests {
|
||||
assert!(json.get("sessionId").is_some());
|
||||
assert!(json.get("toolCallId").is_some());
|
||||
assert!(json.get("planContent").is_some());
|
||||
// Must NOT contain snake_case keys
|
||||
assert!(json.get("session_id").is_none());
|
||||
assert!(json.get("tool_call_id").is_none());
|
||||
assert!(json.get("plan_content").is_none());
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! `grep` tool — new architecture (`Tool` trait).
|
||||
//!
|
||||
//! Wraps ripgrep to search file contents. Reads `Cwd` from Resources and
|
||||
//! truncation settings from its own `Params<GrepParams>`.
|
||||
//!
|
||||
//! The ripgrep binary resolution logic (`rg_path()`) is shared with the
|
||||
//! old implementation via `implementations::grep::ripgrep`.
|
||||
//! `grep` tool: wraps ripgrep to search file contents. Reads `Cwd` from
|
||||
//! Resources and truncation settings from its own `Params<GrepParams>`.
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::LazyLock;
|
||||
@@ -23,15 +18,10 @@ use crate::types::resources::{
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
use crate::util::truncate::truncate_line;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Input
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod ripgrep;
|
||||
|
||||
// Re-export the shared KigiIntegerSchema from types module
|
||||
pub use crate::types::KigiIntegerSchema;
|
||||
use ripgrep::rg_path;
|
||||
|
||||
@@ -127,28 +117,17 @@ pub struct GrepSearchInput {
|
||||
pub multiline: Option<bool>,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Params
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-tool configuration for `grep`.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GrepParams {
|
||||
/// Maximum output size in bytes before truncation.
|
||||
/// Defaults to `DEFAULT_TOOL_OUTPUT_BYTES` (40 KB) when `None`.
|
||||
/// `None` means `DEFAULT_TOOL_OUTPUT_BYTES` (40 KB).
|
||||
pub max_output_bytes: Option<usize>,
|
||||
/// Maximum characters per line before truncation.
|
||||
/// Defaults to 1000 when `None`.
|
||||
/// `None` means `DEFAULT_MAX_CHARS_PER_LINE`.
|
||||
pub max_chars_per_line: Option<usize>,
|
||||
}
|
||||
|
||||
crate::register_resource!("kigi", "Grep", GrepParams);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Hard max when the model passes an explicit `head_limit` (content lines).
|
||||
const CONTENT_LINE_LIMIT: usize = 2_000;
|
||||
/// Default when `head_limit` is omitted (content). Chosen near observed agent
|
||||
@@ -170,13 +149,11 @@ const MAX_STDOUT_BYTES: usize = 5_000_000;
|
||||
/// and discard the already-buffered matches via `grep_timeout_output`.
|
||||
const EXACT_FIT_PROBE_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Default grep wall-clock timeout (seconds) on non-WSL platforms.
|
||||
const GREP_TIMEOUT_DEFAULT_SECS: u64 = 20;
|
||||
|
||||
/// Grep wall-clock timeout (seconds) under WSL, where filesystem reads are 3-5x slower.
|
||||
/// Higher than the default because WSL filesystem reads are 3-5x slower.
|
||||
const GREP_TIMEOUT_WSL_SECS: u64 = 60;
|
||||
|
||||
/// Grep's wall-clock timeout in whole seconds: 60s on WSL (slow filesystem), 20s elsewhere.
|
||||
fn grep_timeout_secs(is_wsl: bool) -> u64 {
|
||||
if is_wsl {
|
||||
GREP_TIMEOUT_WSL_SECS
|
||||
@@ -185,13 +162,10 @@ fn grep_timeout_secs(is_wsl: bool) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grep's wall-clock timeout for the current platform.
|
||||
fn grep_timeout() -> Duration {
|
||||
Duration::from_secs(grep_timeout_secs(kigi_tty_utils::is_wsl()))
|
||||
}
|
||||
|
||||
/// Resolve the effective line/entry budget for this call.
|
||||
///
|
||||
/// Always returns a finite limit so we can stop reading (and kill `rg`) once
|
||||
/// enough output is in hand — even when the model omits `head_limit`.
|
||||
fn resolve_effective_head_limit(input: &GrepSearchInput, output_mode: &OutputMode) -> usize {
|
||||
@@ -204,9 +178,9 @@ fn resolve_effective_head_limit(input: &GrepSearchInput, output_mode: &OutputMod
|
||||
|
||||
/// Hard `head_limit` ceiling for a mode (what an explicit limit is clamped to).
|
||||
///
|
||||
/// Callers that paginate over the full underlying result themselves
|
||||
/// must request this instead of `head_limit: None`, which
|
||||
/// now resolves to the small omitted-`head_limit` default and kills `rg` early.
|
||||
/// Callers that paginate over the full underlying result themselves must
|
||||
/// request this rather than `head_limit: None`, which resolves to the small
|
||||
/// omitted-`head_limit` default and kills `rg` early.
|
||||
pub fn max_head_limit(output_mode: &OutputMode) -> usize {
|
||||
match output_mode {
|
||||
OutputMode::Content => CONTENT_LINE_LIMIT,
|
||||
@@ -214,10 +188,9 @@ pub fn max_head_limit(output_mode: &OutputMode) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// grep's capabilities incl. its streaming spec (single source of truth).
|
||||
/// grep streams the formatted card body (`PlainText` / `Append`), never raw
|
||||
/// stdout; the `<workspace_result …>` wrapper and "Found N …" summary are a
|
||||
/// terminal-only footer, so the stream is a faithful prefix of the card body.
|
||||
/// grep streams the formatted card body, never raw stdout; the
|
||||
/// `<workspace_result …>` wrapper and "Found N …" summary are terminal-only, so
|
||||
/// the stream stays a faithful prefix of the card body.
|
||||
static GREP_CAPABILITIES: LazyLock<kigi_tool_protocol::ToolCapabilities> =
|
||||
LazyLock::new(|| kigi_tool_protocol::ToolCapabilities {
|
||||
is_read_only: true,
|
||||
@@ -229,10 +202,6 @@ static GREP_CAPABILITIES: LazyLock<kigi_tool_protocol::ToolCapabilities> =
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool implementation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GrepTool;
|
||||
|
||||
@@ -275,38 +244,34 @@ impl kigi_tool_runtime::Tool for GrepTool {
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
||||
// Clone of `GREP_CAPABILITIES`; read at registration time only.
|
||||
GREP_CAPABILITIES.clone()
|
||||
}
|
||||
|
||||
/// Streaming entry point. Gate OFF (default): byte-for-byte the blocking
|
||||
/// [`GrepTool::run`] contract. Gate ON: spawn ripgrep, project each match
|
||||
/// line via [`BodyStreamer`] (same projection [`finalize_grep`] re-derives
|
||||
/// in batch) and emit `grep_match_chunk` deltas — the stream is a faithful
|
||||
/// prefix of the terminal card body. Gated by
|
||||
/// `WorkspaceViewerContext::stream_tool_progress`.
|
||||
/// Streaming entry point, gated by
|
||||
/// `WorkspaceViewerContext::stream_tool_progress`. Gate off: defers to the
|
||||
/// blocking [`GrepTool::run`]. Gate on: spawns ripgrep and projects each
|
||||
/// match line via [`BodyStreamer`] — the same projection [`finalize_grep`]
|
||||
/// re-derives in batch, so the deltas are a faithful prefix of the terminal
|
||||
/// card body.
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: kigi_tool_runtime::ToolCallContext,
|
||||
input: GrepSearchInput,
|
||||
) -> kigi_tool_runtime::ToolStream<GrepSearchOutput> {
|
||||
// Absent extension or spec ⇒ gate off. `Some(spec)` iff the gate is
|
||||
// on; the spec borrow is `'static` (LazyLock), so it moves straight
|
||||
// into the stream below.
|
||||
// Absent extension or spec ⇒ gate off. The spec borrow is `'static`
|
||||
// (LazyLock), so it moves straight into the stream below.
|
||||
let admitted_spec = ctx
|
||||
.get::<kigi_tool_runtime::WorkspaceViewerContext>()
|
||||
.zip(GREP_CAPABILITIES.streaming.as_ref())
|
||||
.filter(|(vctx, _)| vctx.stream_tool_progress)
|
||||
.map(|(_, spec)| spec);
|
||||
|
||||
// Fast path: gate off ⇒ run the blocking implementation and wrap its
|
||||
// single result. Identical to the pre-streaming contract.
|
||||
let Some(spec) = admitted_spec else {
|
||||
return kigi_tool_runtime::terminal_only(self.run(ctx, input).await);
|
||||
};
|
||||
|
||||
// `tool.grep` span matching `run`'s; a guard can't be held across
|
||||
// the stream's await points, so the handle is scoped explicitly.
|
||||
// A span guard can't be held across the stream's await points, so the
|
||||
// handle is passed down and entered explicitly instead.
|
||||
let span = tracing::info_span!(
|
||||
"tool.grep",
|
||||
timed_out = tracing::field::Empty,
|
||||
@@ -351,11 +316,10 @@ impl kigi_tool_runtime::Tool for GrepTool {
|
||||
|
||||
let timeout = grep_timeout();
|
||||
let io_result = tokio::time::timeout(timeout, async {
|
||||
// Read stdout until EOF, byte cap, or one line past the budget.
|
||||
// Reading `effective_head_limit + 1` lines lets us distinguish an
|
||||
// exact-fit result (not truncated) from an overflowing one, so we
|
||||
// never flag truncation when there are exactly `effective_head_limit`
|
||||
// lines — matching `finalize_grep`'s `> limit` check.
|
||||
// `+ 1`: reading one line past the budget distinguishes an
|
||||
// exact-fit result (not truncated) from an overflowing one, so a
|
||||
// result of exactly `effective_head_limit` lines is never flagged
|
||||
// truncated — matching `finalize_grep`'s `> limit` check.
|
||||
let (stdout_buf, stdout_truncated) = if let Some(stdout_pipe) = stdout_pipe {
|
||||
read_rg_stdout_capped(stdout_pipe, config.effective_head_limit.saturating_add(1))
|
||||
.await
|
||||
@@ -368,13 +332,12 @@ impl kigi_tool_runtime::Tool for GrepTool {
|
||||
// `rg` only observes that on its next match write; until then it holds
|
||||
// stderr open, so `read_to_end` would block until `rg` exits or the
|
||||
// outer timeout fires — the latter returns `grep_timeout_output` and
|
||||
// drops the matches we already buffered (the same failure the
|
||||
// discards the already-buffered matches (the same failure the
|
||||
// exact-fit probe bound guards against, one step later).
|
||||
if stdout_truncated {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
// Read stderr (always small).
|
||||
let mut stderr_buf = Vec::new();
|
||||
if let Some(stderr_pipe) = stderr_pipe {
|
||||
let _ = stderr_pipe
|
||||
@@ -400,8 +363,8 @@ impl kigi_tool_runtime::Tool for GrepTool {
|
||||
}
|
||||
};
|
||||
|
||||
// `rg` was already killed inside the timeout block when `stdout_truncated`
|
||||
// (before the stderr drain); just reap it here.
|
||||
// When `stdout_truncated`, `rg` is already killed (inside the timeout
|
||||
// block, before the stderr drain); this only reaps it.
|
||||
let status = child.wait().await.ok();
|
||||
let exit_code = if stdout_truncated {
|
||||
0
|
||||
@@ -429,8 +392,6 @@ impl kigi_tool_runtime::Tool for GrepTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming grep pipeline: spawn ripgrep, project each match line via
|
||||
/// `BodyStreamer`, and emit deltas before the terminal card.
|
||||
fn grep_progress_stream(
|
||||
ctx: kigi_tool_runtime::ToolCallContext,
|
||||
input: GrepSearchInput,
|
||||
@@ -447,8 +408,8 @@ fn grep_progress_stream(
|
||||
} = match prepare_grep(&ctx, &input).await {
|
||||
Ok(GrepStep::Ready(ready)) => ready,
|
||||
Ok(GrepStep::Early(out)) => {
|
||||
// Mirror `run`'s Early arm so path-not-found / spawn short-circuits
|
||||
// still populate the `tool.grep` span in the streaming (prod) path.
|
||||
// Mirrors `run`'s Early arm so path-not-found / spawn
|
||||
// short-circuits still populate the `tool.grep` span here.
|
||||
span.record("wall_ms", stream_started.elapsed().as_millis() as u64);
|
||||
span.record("early_kill", false);
|
||||
yield kigi_tool_runtime::ToolStreamItem::Terminal(Ok(out));
|
||||
@@ -460,24 +421,22 @@ fn grep_progress_stream(
|
||||
}
|
||||
};
|
||||
|
||||
// Raw bytes for the authoritative terminal card.
|
||||
span.record("effective_head_limit", config.effective_head_limit as u64);
|
||||
// Raw bytes, kept for the authoritative terminal card.
|
||||
let mut stdout_buf = Vec::with_capacity(MAX_STDOUT_BYTES.min(65_536));
|
||||
let mut stdout_truncated = false;
|
||||
// Incremental card-body formatter (deltas == terminal body).
|
||||
let mut streamer = BodyStreamer::new(spec, &config);
|
||||
let mut timed_out = false;
|
||||
// Complete newlines accepted into `stdout_buf` (same budget as
|
||||
// `read_rg_stdout_capped` / `finalize_grep`).
|
||||
let mut complete_lines = 0usize;
|
||||
// One deadline shared by stdout loop + stderr drain (same total
|
||||
// budget as `run`).
|
||||
// One deadline shared by the stdout loop and the stderr drain, so the
|
||||
// total budget matches `run`'s.
|
||||
let timeout = grep_timeout();
|
||||
let deadline_at = tokio::time::Instant::now() + timeout;
|
||||
|
||||
if let Some(mut stdout_pipe) = stdout_pipe {
|
||||
let mut tmp = [0u8; 8192];
|
||||
// Deadline rides the `select!` (can't wrap a yielding block).
|
||||
// The deadline rides the `select!` because `tokio::time::timeout`
|
||||
// cannot wrap a block that yields.
|
||||
let deadline = tokio::time::sleep_until(deadline_at);
|
||||
tokio::pin!(deadline);
|
||||
loop {
|
||||
@@ -493,8 +452,6 @@ fn grep_progress_stream(
|
||||
Ok(n) => n,
|
||||
Err(_) => break,
|
||||
};
|
||||
// Mirror `run`'s hard byte + line caps when filling
|
||||
// `stdout_buf`, then kill so rg stops walking the tree.
|
||||
// `+ 1`: read one line past the budget so truncation is
|
||||
// only flagged when there are genuinely MORE than
|
||||
// `effective_head_limit` lines (matches `run` /
|
||||
@@ -514,23 +471,21 @@ fn grep_progress_stream(
|
||||
stdout_buf.extend_from_slice(&tmp[..accepted]);
|
||||
}
|
||||
|
||||
// Project + emit each newly completed line BEFORE the
|
||||
// exact-fit probe below: the probe reads into `tmp`,
|
||||
// overwriting the just-accepted bytes, so feeding after
|
||||
// it would stream corrupted data (the terminal card is
|
||||
// rebuilt from `stdout_buf`, but streamed deltas must
|
||||
// stay a faithful prefix of it).
|
||||
// Must run BEFORE the exact-fit probe below: the probe
|
||||
// reads into `tmp`, overwriting the just-accepted bytes,
|
||||
// so feeding afterwards would stream corrupted data.
|
||||
for p in streamer.feed(&tmp[..accepted]) {
|
||||
yield kigi_tool_runtime::ToolStreamItem::Progress(p);
|
||||
}
|
||||
|
||||
if hit_cap {
|
||||
// Same short exact-fit probe as `read_rg_stdout_capped`.
|
||||
// Use ONLY `EXACT_FIT_PROBE_TIMEOUT` — never the shared
|
||||
// tool `deadline_at`. Clamping the probe to `deadline_at`
|
||||
// and setting `timed_out` on expiry would force the
|
||||
// timeout terminal branch (banner, exit -1) for a
|
||||
// normal head-limit fill near the wall-clock edge.
|
||||
// Same exact-fit probe as `read_rg_stdout_capped`,
|
||||
// bounded ONLY by `EXACT_FIT_PROBE_TIMEOUT` — never
|
||||
// by the shared `deadline_at`. Clamping it to
|
||||
// `deadline_at` and setting `timed_out` on expiry
|
||||
// would force the timeout terminal branch (exit -1)
|
||||
// for an ordinary head-limit fill that happens to
|
||||
// land near the wall-clock edge.
|
||||
if accepted < n {
|
||||
stdout_truncated = true;
|
||||
} else {
|
||||
@@ -543,17 +498,17 @@ fn grep_progress_stream(
|
||||
Ok(Ok(0)) => stdout_truncated = false,
|
||||
Ok(Ok(_)) => stdout_truncated = true,
|
||||
Ok(Err(_)) => stdout_truncated = true,
|
||||
// Probe budget only: head-limit truncation path
|
||||
// (keep buffer, kill `rg` below). Never set
|
||||
// `timed_out` here.
|
||||
// Probe budget only — treat as head-limit
|
||||
// truncation (keep the buffer, kill `rg`
|
||||
// below); never set `timed_out` here.
|
||||
Err(_elapsed) => stdout_truncated = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also stop once the formatted body has hit its own
|
||||
// head/byte budget (may trip before raw line count when
|
||||
// max_output_bytes is small).
|
||||
// The formatted body has its own head/byte budget, which
|
||||
// can trip before the raw line count when
|
||||
// `max_output_bytes` is small.
|
||||
if hit_cap || streamer.done {
|
||||
if streamer.done {
|
||||
stdout_truncated = true;
|
||||
@@ -575,9 +530,9 @@ fn grep_progress_stream(
|
||||
});
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
// Timeout: finalize what was read (marked truncated) plus an
|
||||
// explicit notice, so the stream isn't contradicted; with
|
||||
// nothing streamed, fall back to the timeout-only card.
|
||||
// Finalize what was read (marked truncated) plus an explicit
|
||||
// notice, so the terminal card does not contradict what was already
|
||||
// streamed; with nothing streamed, the timeout-only card suffices.
|
||||
if stdout_buf.is_empty() {
|
||||
yield kigi_tool_runtime::ToolStreamItem::Terminal(Ok(grep_timeout_output(secs)));
|
||||
} else {
|
||||
@@ -600,7 +555,6 @@ fn grep_progress_stream(
|
||||
}
|
||||
span.record("timed_out", false);
|
||||
|
||||
// Flush the final non-terminated segment (see `BodyStreamer::finish`).
|
||||
if let Some(p) = streamer.finish() {
|
||||
yield kigi_tool_runtime::ToolStreamItem::Progress(p);
|
||||
}
|
||||
@@ -608,14 +562,13 @@ fn grep_progress_stream(
|
||||
// Kill the child **before** draining stderr when we stopped early
|
||||
// (byte/line/format cap); rg may still be walking the tree and only
|
||||
// notices the closed stdout on its next write, so a stderr drain first
|
||||
// would stall until the deadline (up to the full timeout) even though we
|
||||
// already have a full budget.
|
||||
// would stall until the deadline even though a full budget is in hand.
|
||||
if stdout_truncated {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
// stderr is small and never streamed; still bounded by the shared
|
||||
// deadline as a backstop so a wedged child can't stall the stream.
|
||||
// Bounded by the shared deadline as a backstop so a wedged child cannot
|
||||
// stall the stream.
|
||||
let mut stderr_buf = Vec::new();
|
||||
if let Some(stderr_pipe) = stderr_pipe {
|
||||
let _ = tokio::time::timeout_at(
|
||||
@@ -651,20 +604,15 @@ fn grep_progress_stream(
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Execution helpers (shared by `run` and `execute`)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Formatting/projection knobs resolved once in [`prepare_grep`] and consumed by
|
||||
/// both the streamed body ([`BodyStreamer`]) and the terminal card
|
||||
/// ([`finalize_grep`]) so the two never drift.
|
||||
/// Formatting knobs resolved once in [`prepare_grep`] and consumed by both the
|
||||
/// streamed body ([`BodyStreamer`]) and the terminal card ([`finalize_grep`]),
|
||||
/// so the two projections never drift.
|
||||
struct GrepFormatConfig {
|
||||
output_mode: OutputMode,
|
||||
/// Line/entry budget: model `head_limit` clamped to the per-mode cap, or
|
||||
/// the per-mode default when omitted. Always finite so we can kill `rg`
|
||||
/// once enough output is collected.
|
||||
/// Model `head_limit` clamped to the per-mode cap, or the per-mode default
|
||||
/// when omitted. Always finite so `rg` can be killed once enough output is
|
||||
/// collected.
|
||||
effective_head_limit: usize,
|
||||
/// Per-line truncation width (`trim_line`).
|
||||
max_chars_per_line: usize,
|
||||
/// Cumulative body byte cap.
|
||||
max_output_bytes: usize,
|
||||
@@ -672,7 +620,6 @@ struct GrepFormatConfig {
|
||||
cwd_display: String,
|
||||
}
|
||||
|
||||
/// A spawned ripgrep ready to be read, plus the resolved formatting config.
|
||||
struct GrepReady {
|
||||
child: Child,
|
||||
stdout_pipe: Option<ChildStdout>,
|
||||
@@ -680,16 +627,14 @@ struct GrepReady {
|
||||
config: GrepFormatConfig,
|
||||
}
|
||||
|
||||
/// Outcome of [`prepare_grep`]: either a spawned process to read, or a fully
|
||||
/// formed early result (path-not-found / spawn failure) that needs no reading.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum GrepStep {
|
||||
Ready(GrepReady),
|
||||
/// A fully formed result (path-not-found / spawn failure) that needs no
|
||||
/// reading.
|
||||
Early(GrepSearchOutput),
|
||||
}
|
||||
|
||||
/// Resolve resources, build the ripgrep command, and spawn it; `Early` for
|
||||
/// pre-read short-circuits. Shared by `run` and `execute`.
|
||||
async fn prepare_grep(
|
||||
ctx: &kigi_tool_runtime::ToolCallContext,
|
||||
input: &GrepSearchInput,
|
||||
@@ -708,24 +653,20 @@ async fn prepare_grep(
|
||||
)
|
||||
};
|
||||
|
||||
// Resolve the model-provided path for the working directory.
|
||||
let workdir = resolve_model_path(
|
||||
&cwd,
|
||||
display_cwd.as_deref(),
|
||||
input.path.as_deref().unwrap_or(""),
|
||||
);
|
||||
// Use display_cwd for output paths so model sees stable paths.
|
||||
// Output paths are rendered against `display_cwd` so the model sees stable
|
||||
// paths regardless of the real cwd.
|
||||
let display_base = display_cwd_or_cwd(&cwd, display_cwd.as_deref());
|
||||
let cwd_display = display_base.display().to_string();
|
||||
|
||||
// Pre-check: if the search path doesn't exist, return enriched hints
|
||||
// before rg runs. We intentionally pre-check with metadata() rather
|
||||
// than parsing rg's stderr after the fact because rg lumps all errors
|
||||
// under exit code 2 (path not found, invalid regex, bad glob, unknown
|
||||
// file type, etc.). Distinguishing path-not-found would require
|
||||
// matching on OS error strings in stderr, which is fragile. The
|
||||
// pre-check avoids that and keeps the exit-code-2 handler below
|
||||
// unchanged for all other rg error classes.
|
||||
// Path-not-found is detected up front with `metadata()` rather than by
|
||||
// parsing rg's stderr, because rg lumps every error under exit code 2 (path
|
||||
// not found, invalid regex, bad glob, unknown file type, …); telling them
|
||||
// apart would mean matching OS error strings, which is fragile.
|
||||
if input.path.is_some()
|
||||
&& let Err(e) = tokio::fs::metadata(&workdir).await
|
||||
&& e.kind() == std::io::ErrorKind::NotFound
|
||||
@@ -778,8 +719,8 @@ async fn prepare_grep(
|
||||
|
||||
// Managed Read-deny globs become ripgrep excludes so a search never reads
|
||||
// a policy-forbidden path — whether reached by a recursive walk or by a
|
||||
// `glob` arg that targets a denied file. Added AFTER the caller's `--glob`
|
||||
// so the exclude wins (ripgrep applies the last matching glob). An
|
||||
// `glob` arg that targets a denied file. These must follow the caller's
|
||||
// `--glob` so the exclude wins: ripgrep applies the last matching glob. An
|
||||
// explicitly-passed denied `path` is blocked earlier by the permission
|
||||
// manager (ripgrep searches explicit paths even against excludes).
|
||||
for deny in &deny_read_globs {
|
||||
@@ -843,12 +784,10 @@ async fn prepare_grep(
|
||||
}
|
||||
};
|
||||
|
||||
// Take pipes so child remains accessible for cleanup on timeout.
|
||||
// Taken out of `child` so `child` stays available for kill/reap on timeout.
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
// Resolve truncation settings from tool-specific Params (static config; no
|
||||
// dependency on the rg output, so it is resolved up front).
|
||||
let params = resources
|
||||
.lock()
|
||||
.await
|
||||
@@ -881,8 +820,7 @@ async fn prepare_grep(
|
||||
/// Longest prefix of `bytes` that ends on a UTF-8 character boundary.
|
||||
///
|
||||
/// Used when a hard *byte* budget would otherwise cut mid-code-unit; line-budget
|
||||
/// stops already land on `\n` (ASCII), so they are always boundaries. Counting
|
||||
/// lines by `b'\n'` is UTF-8-safe (newlines are never multi-byte).
|
||||
/// stops already land on `\n` (single-byte ASCII), so they are always boundaries.
|
||||
fn utf8_char_boundary_prefix_len(bytes: &[u8]) -> usize {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(_) => bytes.len(),
|
||||
@@ -894,13 +832,12 @@ fn utf8_char_boundary_prefix_len(bytes: &[u8]) -> usize {
|
||||
/// running byte/line budgets. Returns `(accepted_len, hit_cap)`.
|
||||
///
|
||||
/// Stops at the first of: remaining room under [`MAX_STDOUT_BYTES`], or the
|
||||
/// newline that brings complete line count to `max_lines`. Used by both the
|
||||
/// newline that brings complete line count to `max_lines`. Shared by the
|
||||
/// blocking and streaming read loops so early-kill behavior cannot drift.
|
||||
///
|
||||
/// On a pure byte-cap stop (no line budget hit), the accepted slice is snapped
|
||||
/// to a UTF-8 char boundary so we never append a partial multi-byte sequence
|
||||
/// into `stdout_buf` (downstream uses `String::from_utf8_lossy`, but mid-char
|
||||
/// cuts also break incremental `BodyStreamer` line assembly).
|
||||
/// On a pure byte-cap stop, the accepted slice is snapped to a UTF-8 char
|
||||
/// boundary so no partial multi-byte sequence lands in `stdout_buf`: a mid-char
|
||||
/// cut would break incremental `BodyStreamer` line assembly.
|
||||
fn accept_rg_stdout_chunk(
|
||||
chunk: &[u8],
|
||||
buf_len: usize,
|
||||
@@ -921,17 +858,17 @@ fn accept_rg_stdout_chunk(
|
||||
if b == b'\n' {
|
||||
lines += 1;
|
||||
if lines >= max_lines {
|
||||
// Include the newline that filled the budget, then stop.
|
||||
// `\n` is a single-byte ASCII boundary — no UTF-8 snap needed.
|
||||
// Include the `\n` that filled the budget; it is a single-byte
|
||||
// ASCII boundary, so no UTF-8 snap is needed.
|
||||
return (i + 1, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let hit_byte_cap = limited.len() < chunk.len();
|
||||
if hit_byte_cap {
|
||||
// Prefer a complete UTF-8 prefix over a mid-code-unit cut. If the entire
|
||||
// limited slice is an incomplete sequence (shouldn't happen when the
|
||||
// prior buffer always ends on a boundary), accept 0 and hit the cap.
|
||||
// Prefer a complete UTF-8 prefix over a mid-code-unit cut. A fully
|
||||
// incomplete slice (which shouldn't occur when the prior buffer always
|
||||
// ends on a boundary) accepts 0 and still hits the cap.
|
||||
let safe = utf8_char_boundary_prefix_len(limited);
|
||||
return (safe, true);
|
||||
}
|
||||
@@ -940,13 +877,13 @@ fn accept_rg_stdout_chunk(
|
||||
|
||||
/// Read `rg` stdout until EOF or a hard stop (byte cap / effective head_limit
|
||||
/// lines). Callers should kill the child when the returned truncated flag is
|
||||
/// set so `rg` does not keep walking the tree.
|
||||
/// When the line budget is filled exactly and the next read is EOF, `truncated`
|
||||
/// is **false** (exact fit). If more bytes remain after the budget, true.
|
||||
/// set so `rg` does not keep walking the tree. When the line budget is filled
|
||||
/// exactly and the next read is EOF, `truncated` is **false** (exact fit);
|
||||
/// otherwise true.
|
||||
///
|
||||
/// The post-budget "exact-fit" probe is **time-bounded** ([`EXACT_FIT_PROBE_TIMEOUT`]).
|
||||
/// An unbounded `read` would hold the outer tool timeout and, on expiry, drop the
|
||||
/// already-buffered matches in favor of a timeout error card.
|
||||
/// An unbounded `read` would hold the outer tool timeout and, on expiry, discard
|
||||
/// the already-buffered matches in favor of a timeout error card.
|
||||
async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -> (Vec<u8>, bool) {
|
||||
let mut buf = Vec::with_capacity(MAX_STDOUT_BYTES.min(65_536));
|
||||
let mut complete_lines = 0usize;
|
||||
@@ -966,7 +903,6 @@ async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -
|
||||
if accepted < n {
|
||||
truncated = true;
|
||||
} else {
|
||||
// Bounded probe: never wait for the full tool timeout here.
|
||||
match tokio::time::timeout(
|
||||
EXACT_FIT_PROBE_TIMEOUT,
|
||||
stdout_pipe.read(&mut tmp),
|
||||
@@ -976,9 +912,10 @@ async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -
|
||||
Ok(Ok(0)) => truncated = false,
|
||||
Ok(Ok(_)) => truncated = true,
|
||||
Ok(Err(_)) => truncated = true,
|
||||
// No more data arrived quickly — assume overflow so the
|
||||
// caller kills `rg` and keeps the buffer (do not escalate
|
||||
// to the outer timeout path that drops matches).
|
||||
// No more data arrived quickly: assume overflow so the
|
||||
// caller kills `rg` and keeps the buffer, rather than
|
||||
// escalating to the outer timeout path that discards
|
||||
// matches.
|
||||
Err(_elapsed) => truncated = true,
|
||||
}
|
||||
}
|
||||
@@ -1008,8 +945,8 @@ fn grep_timeout_output(secs: u64) -> GrepSearchOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the authoritative terminal card from the fully-read rg output.
|
||||
/// Single source of truth; the streamed body is a faithful prefix of it.
|
||||
/// Build the authoritative terminal card from the fully-read rg output. Single
|
||||
/// source of truth; the streamed body is a faithful prefix of it.
|
||||
fn finalize_grep(
|
||||
stdout_buf: Vec<u8>,
|
||||
stdout_truncated: bool,
|
||||
@@ -1020,7 +957,6 @@ fn finalize_grep(
|
||||
let stdout = String::from_utf8_lossy(&stdout_buf);
|
||||
let stderr = String::from_utf8_lossy(&stderr_buf);
|
||||
|
||||
// Handle exit codes.
|
||||
if (exit_code == 1 && stdout.is_empty())
|
||||
|| (exit_code == 2 && stderr.contains("No files were searched"))
|
||||
{
|
||||
@@ -1134,18 +1070,18 @@ fn finalize_grep(
|
||||
/// Incremental builder for grep's streamed card body: raw stdout in via
|
||||
/// [`BodyStreamer::feed`], flushed at EOF via [`BodyStreamer::finish`]. Each
|
||||
/// line is projected exactly as [`finalize_grep`] projects the terminal body,
|
||||
/// so the concatenated deltas equal the card body (prefix mode). Line
|
||||
/// splitting matches `str::lines()` exactly (incl. trailing-`\r` handling).
|
||||
/// so the concatenated deltas equal the card body. Line splitting matches
|
||||
/// `str::lines()` exactly, including trailing-`\r` handling.
|
||||
struct BodyStreamer<'a> {
|
||||
spec: &'a kigi_tool_protocol::StreamingSpec,
|
||||
config: &'a GrepFormatConfig,
|
||||
/// Accumulated card body. Equals the body `finalize_grep` produces.
|
||||
/// Equals the body `finalize_grep` produces.
|
||||
body: String,
|
||||
/// Monotonic body bytes already surfaced as deltas.
|
||||
last_total: u64,
|
||||
/// Body lines emitted so far (drives the head-limit).
|
||||
/// Drives the head-limit.
|
||||
emitted_lines: usize,
|
||||
/// Cumulative trimmed-line length (drives the byte-cap).
|
||||
/// Cumulative trimmed-line length; drives the byte-cap.
|
||||
cum_len: usize,
|
||||
/// Set once the head-limit or byte-cap is hit (body complete).
|
||||
done: bool,
|
||||
@@ -1175,15 +1111,17 @@ impl<'a> BodyStreamer<'a> {
|
||||
return deltas;
|
||||
}
|
||||
self.pending.extend_from_slice(bytes);
|
||||
// Own the buffer to project lines from borrowed slices (no per-line
|
||||
// alloc); the unconsumed tail is carried forward at the end.
|
||||
// Owned so lines can be projected from borrowed slices without a
|
||||
// per-line alloc; the unconsumed tail is carried forward at the end.
|
||||
let buf = std::mem::take(&mut self.pending);
|
||||
let mut start = 0;
|
||||
while let Some(rel) = buf[start..].iter().position(|&b| b == b'\n') {
|
||||
let nl = start + rel;
|
||||
let mut end = nl; // exclusive; drops the '\n'
|
||||
// Exclusive end, dropping the '\n' and, for a "\r\n", the '\r' too
|
||||
// (matches `str::lines()`).
|
||||
let mut end = nl;
|
||||
if end > start && buf[end - 1] == b'\r' {
|
||||
end -= 1; // drop the '\r' of a '\r\n' (matches `str::lines()`)
|
||||
end -= 1;
|
||||
}
|
||||
if let Some(p) = self.push_line(&buf[start..end]) {
|
||||
deltas.push(p);
|
||||
@@ -1193,7 +1131,6 @@ impl<'a> BodyStreamer<'a> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Carry the in-progress (post-last-newline) bytes to the next feed.
|
||||
self.pending.extend_from_slice(&buf[start..]);
|
||||
deltas
|
||||
}
|
||||
@@ -1209,22 +1146,20 @@ impl<'a> BodyStreamer<'a> {
|
||||
}
|
||||
|
||||
/// Project one line into the body; returns its delta. Sets [`Self::done`]
|
||||
/// at the head-limit or byte-cap.
|
||||
/// at the head-limit or byte-cap, both matching `finalize_grep`.
|
||||
fn push_line(&mut self, line: &[u8]) -> Option<kigi_tool_runtime::ToolProgress> {
|
||||
// Head-limit (matches `finalize_grep`).
|
||||
if self.emitted_lines >= self.config.effective_head_limit {
|
||||
self.done = true;
|
||||
return None;
|
||||
}
|
||||
let line_str = String::from_utf8_lossy(line);
|
||||
let trimmed = trim_line(&line_str, self.config.max_chars_per_line);
|
||||
// Byte-cap; shares `exceeds_cum_byte_cap` with the batch path.
|
||||
if exceeds_cum_byte_cap(self.cum_len, trimmed.len(), self.config.max_output_bytes) {
|
||||
self.done = true;
|
||||
return None;
|
||||
}
|
||||
// Separator keyed off `emitted_lines` so a leading empty line still
|
||||
// gets one.
|
||||
// Keyed off `emitted_lines` so a leading empty line still gets a
|
||||
// separator.
|
||||
if self.emitted_lines > 0 {
|
||||
self.body.push('\n');
|
||||
}
|
||||
@@ -1236,25 +1171,19 @@ impl<'a> BodyStreamer<'a> {
|
||||
self.body.as_bytes(),
|
||||
self.body.len() as u64,
|
||||
&mut self.last_total,
|
||||
// No upstream cumulative truncation; only the per-tick `gap`.
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Parsing & formatting helpers (free functions)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn trim_line(line: &str, max_chars_per_line: usize) -> String {
|
||||
truncate_line(line, max_chars_per_line).into_owned()
|
||||
}
|
||||
|
||||
/// Parse a ripgrep "numbered line" prefix: `123:content` or `45-context`.
|
||||
///
|
||||
/// `pub` so siblings can reuse the parser instead of
|
||||
/// duplicating it -- avoids drift between the two namespaces' rg-output
|
||||
/// reformatters.
|
||||
/// `pub` so sibling namespaces' rg-output reformatters reuse this parser rather
|
||||
/// than duplicating (and drifting from) it.
|
||||
pub fn parse_numbered_line_prefix(line: &str) -> Option<(usize, char, &str)> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut idx = 0usize;
|
||||
@@ -1461,10 +1390,6 @@ pub fn format_count_output(
|
||||
final_output_lines.join("\n")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1896,13 +1821,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn tool_uses_params_for_truncation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Create a file with many matching lines
|
||||
let content: String = (0..100).map(|i| format!("match_line_{}\n", i)).collect();
|
||||
fs::write(tmp.path().join("big.txt"), &content).unwrap();
|
||||
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(tmp.path().to_path_buf()));
|
||||
// Set a very small output limit
|
||||
resources.insert(Params(GrepParams {
|
||||
max_output_bytes: Some(200),
|
||||
max_chars_per_line: None,
|
||||
@@ -2104,10 +2027,8 @@ mod tests {
|
||||
assert!(stdout.contains("secret_value"));
|
||||
}
|
||||
|
||||
// ─── Streaming (GrepTool::execute) tests ───
|
||||
//
|
||||
// `test_ctx` stamps `WorkspaceViewerContext { stream_tool_progress: true }`,
|
||||
// so these exercise the streaming path.
|
||||
// so the tests below exercise the streaming path.
|
||||
|
||||
/// Destructure a `grep_match_chunk` payload, asserting the canonical
|
||||
/// `plain_text` / `append` envelope. Returns the `delta`.
|
||||
@@ -2230,8 +2151,8 @@ mod tests {
|
||||
/// Byte-cap must not cut mid multi-byte UTF-8 sequence (e.g. "é" = C3 A9).
|
||||
#[test]
|
||||
fn accept_rg_stdout_chunk_byte_cap_snaps_to_utf8_boundary() {
|
||||
// One byte of room left, but next char is 2-byte UTF-8.
|
||||
let chunk = "é\n".as_bytes(); // [0xC3, 0xA9, 0x0A]
|
||||
// One byte of room left, but next char is 2-byte UTF-8 ("é" = C3 A9).
|
||||
let chunk = "é\n".as_bytes();
|
||||
assert_eq!(chunk.len(), 3);
|
||||
let (n, hit) = accept_rg_stdout_chunk(chunk, MAX_STDOUT_BYTES - 1, 0, 100);
|
||||
assert!(hit, "must hit byte cap");
|
||||
|
||||
@@ -35,11 +35,10 @@ fn resolve_bundled_rg() -> std::io::Result<PathBuf> {
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Get the path to the ripgrep executable.
|
||||
/// Path to the ripgrep executable.
|
||||
///
|
||||
/// In release builds with bundling enabled, this extracts the bundled ripgrep
|
||||
/// binary to ~/.kigi/vendor/ and returns that path.
|
||||
/// Otherwise, assumes `rg` is in PATH.
|
||||
/// With bundling enabled this extracts the embedded binary to `~/.kigi/vendor/`
|
||||
/// on first call; otherwise it falls back to `rg` on PATH.
|
||||
pub fn rg_path() -> PathBuf {
|
||||
static RG_EXEC: OnceLock<PathBuf> = OnceLock::new();
|
||||
RG_EXEC
|
||||
@@ -50,14 +49,12 @@ pub fn rg_path() -> PathBuf {
|
||||
}
|
||||
#[cfg(not(bundle_rg))]
|
||||
{
|
||||
// RG_BIN_PATH: explicit override (tests / packaging can set this).
|
||||
if let Ok(p) = std::env::var("RG_BIN_PATH") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
// Some hermetic test runners set RUNFILES_DIR and ship rg as a
|
||||
// data dependency rather than on PATH. Scan for a directory
|
||||
// entry containing "ripgrep_hermetic" and prefer arch-scoped
|
||||
// paths when present.
|
||||
// Hermetic test runners set RUNFILES_DIR and ship rg as a data
|
||||
// dependency rather than on PATH, under a directory whose name
|
||||
// contains "ripgrep_hermetic".
|
||||
if let Ok(rf) = std::env::var("RUNFILES_DIR") {
|
||||
let base = PathBuf::from(rf);
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! `kill_task` tool — new architecture (`Tool` trait).
|
||||
//!
|
||||
//! Terminates a running background task. Reads the `Terminal` resource
|
||||
//! from Resources to access the terminal backend.
|
||||
//! `kill_task` tool: terminates a running background task or subagent.
|
||||
|
||||
pub mod terminal_command;
|
||||
pub use terminal_command::KillTerminalCommandTool;
|
||||
@@ -17,40 +14,24 @@ use crate::types::tool::ToolKind;
|
||||
use crate::types::tool::ToolNamespace;
|
||||
use kigi_tool_types::{KillTaskOutput, KillTaskResult, KillTaskToolInput};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool implementation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KillTaskTool;
|
||||
|
||||
// ── Legacy message helpers ───────────────────────────────────────────────
|
||||
//
|
||||
// Historical fixture captured from an earlier (0.4.10) revision of this tool.
|
||||
//
|
||||
// In 0.4.10, kill_task returned:
|
||||
// Err(ToolError::ProcessManagerError(format!("Task {} not found", input.task_id)))
|
||||
//
|
||||
// The meaningful customer-facing message content is the inner string.
|
||||
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
|
||||
|
||||
/// Exact historical not-found message for `kill_task` in legacy-0.4.10.
|
||||
/// Byte-for-byte fixture of the 0.4.10 message, which clients on the
|
||||
/// `legacy-0.4.10` contract still match against. It carries no subagent
|
||||
/// wording because subagents did not exist in that release.
|
||||
fn render_legacy_kill_task_not_found(task_id: &str) -> String {
|
||||
format!("Task {} not found", task_id)
|
||||
}
|
||||
|
||||
/// Format a "not found" response for kill_task.
|
||||
async fn not_found_response(
|
||||
task_id: &str,
|
||||
terminal: &std::sync::Arc<dyn crate::computer::types::TerminalBackend>,
|
||||
is_legacy: bool,
|
||||
) -> KillTaskOutput {
|
||||
if is_legacy {
|
||||
// Legacy: simple error without task ID enumeration.
|
||||
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
|
||||
return KillTaskOutput::TaskNotFound(render_legacy_kill_task_not_found(task_id));
|
||||
}
|
||||
// Current: include known task IDs for discoverability.
|
||||
let known = terminal.list_tasks().await;
|
||||
let msg = if known.is_empty() {
|
||||
format!(
|
||||
@@ -76,9 +57,8 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
|
||||
}
|
||||
|
||||
fn description_template(&self) -> &str {
|
||||
// Canonical wording lives in the shared builder; `versioned_definition`
|
||||
// renders it context-aware from the finalized toolset. This static
|
||||
// fallback mirrors the default kigi toolset on the current OS.
|
||||
// Only a fallback for callers that skip `versioned_definition`; it
|
||||
// assumes the default kigi toolset rather than the finalized one.
|
||||
static DESC: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
kigi_tool_types::build_kill_task_description(&kigi_tool_types::KillTaskToolNaming {
|
||||
monitor_tool: Some("monitor"),
|
||||
@@ -126,11 +106,10 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the model-facing `kill_task` description from the finalized toolset,
|
||||
/// honoring an explicit config override. Wording lives in the shared
|
||||
/// [`kigi_tool_types::build_kill_task_description`] builder so the CLI and
|
||||
/// prod-chat can't drift; the monitor / subagent / bash clauses follow the
|
||||
/// tools actually registered this turn, and the kill verb follows the host OS.
|
||||
/// Wording lives in the shared [`kigi_tool_types::build_kill_task_description`]
|
||||
/// builder so the CLI and prod-chat can't drift; the monitor / subagent / bash
|
||||
/// clauses follow the tools actually registered this turn, so the description
|
||||
/// never names a tool the model wasn't given.
|
||||
fn kill_task_description(
|
||||
renderer: &TemplateRenderer,
|
||||
description_override: Option<&str>,
|
||||
@@ -209,7 +188,7 @@ impl kigi_tool_runtime::Tool for KillTaskTool {
|
||||
message: "Task had already completed".to_string(),
|
||||
})),
|
||||
KillOutcome::NotFound => {
|
||||
// Try subagent cancel via backend
|
||||
// Not a terminal task; it may still be a subagent.
|
||||
let backend = {
|
||||
resources
|
||||
.lock()
|
||||
@@ -246,10 +225,6 @@ impl kigi_tool_runtime::Tool for KillTaskTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -277,9 +252,7 @@ mod tests {
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Minimal mock backend for testing kill_task.
|
||||
struct MockTerminal {
|
||||
/// Pre-configured outcome for `kill_task` calls.
|
||||
outcome: KO,
|
||||
}
|
||||
|
||||
@@ -331,14 +304,11 @@ mod tests {
|
||||
fn tool_name_and_description() {
|
||||
let tool = KillTaskTool;
|
||||
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "kill_task");
|
||||
// The static fallback is the shared builder's default kigi
|
||||
// rendering (monitor + task + bash present) for the current OS.
|
||||
// Fallback assumes the default toolset, so monitor + task + bash all appear.
|
||||
let desc = crate::types::tool_metadata::ToolMetadata::description_template(&tool);
|
||||
assert!(desc.contains("Terminate"));
|
||||
assert!(desc.contains("subagent"));
|
||||
// Must name "monitor" so the model connects stopping a monitor to this tool.
|
||||
assert!(desc.contains("monitor"));
|
||||
// The kill verb is OS-specific (SIGTERM on POSIX, Job Object on Windows).
|
||||
if cfg!(not(unix)) {
|
||||
assert!(desc.contains("Job Object"), "windows verb: {desc}");
|
||||
} else {
|
||||
@@ -523,7 +493,8 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(); // Should be Ok, not Err
|
||||
// Not-found is a typed output, not an `Err`.
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
KillTaskOutput::TaskNotFound(msg) => {
|
||||
@@ -559,8 +530,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── MP-3: Legacy message parity fixture tests ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_kill_task_not_found_exact_historical_message() {
|
||||
let resources = resources_with_terminal(KO::NotFound);
|
||||
@@ -578,7 +547,6 @@ mod tests {
|
||||
|
||||
match result {
|
||||
KillTaskOutput::TaskNotFound(msg) => {
|
||||
// Exact historical fixture — no trailing period.
|
||||
assert_eq!(msg, "Task task-abc not found");
|
||||
}
|
||||
other => panic!("Expected TaskNotFound, got {:?}", other),
|
||||
@@ -613,11 +581,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subagent cancel via backend tests ─────────────────────────────
|
||||
|
||||
/// Build resources with a terminal that returns `NotFound` and a
|
||||
/// `SubagentBackendResource` backed by channels, returning the cancel
|
||||
/// receiver so the test can simulate the coordinator.
|
||||
/// Terminal returns `NotFound` and a channel-backed `SubagentBackendResource`
|
||||
/// is installed; the returned receiver lets the test play the coordinator.
|
||||
fn resources_with_backend_cancel() -> (
|
||||
Resources,
|
||||
tokio::sync::mpsc::UnboundedReceiver<SubagentEvent>,
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
//! `list_dir` tool — new architecture (`Tool` trait).
|
||||
//! `list_dir` tool — directory listing.
|
||||
//!
|
||||
//! This is the new-architecture implementation of the directory listing tool.
|
||||
//! It reads `Cwd` from `Resources` and `max_output_chars` from its own
|
||||
//! `Params<ListDirParams>` instead of receiving them via `ToolContext`.
|
||||
//! Seeds depth-1 children (capped at `MAX_SEED_ITEMS`) before the budgeted deep
|
||||
//! walk so a fat early sibling cannot starve later top-level dirs
|
||||
//! (`MAX_GLOBAL_ITEMS` applies only to depth ≥ 2), then BFS-expands dirs within
|
||||
//! the char budget. When either the seed or the walk hits its item limit, the
|
||||
//! agent-visible cutoff notice is emitted.
|
||||
//!
|
||||
//! Seeds depth-1 children (capped at `MAX_SEED_ITEMS`) before the budgeted deep walk
|
||||
//! so a fat early sibling cannot starve later top-level dirs (`MAX_GLOBAL_ITEMS`
|
||||
//! applies only to depth ≥ 2). Then BFS-expands dirs within the char budget
|
||||
//! (`continue` on fat dirs). When either seed or walk hits its item limit, the
|
||||
//! agent-visible cutoff notice is emitted (copy unchanged from `main`).
|
||||
//! Partial-output case: when the walk truncates, a sibling surfaced by the seed
|
||||
//! may be listed by name only while its descendants are absent, because the walk
|
||||
//! cap was exhausted inside an earlier sibling.
|
||||
//!
|
||||
//! Partial-output case: when `walk_truncated` is true, a sibling surfaced by the
|
||||
//! seed may be listed by name only while its descendants are absent (the walk cap
|
||||
//! was exhausted inside an earlier sibling). The agent-visible notice copy is
|
||||
//! intentionally left identical to `main`; this behavior is documented in the
|
||||
//! CHANGELOG rather than via new model-facing wording.
|
||||
//!
|
||||
//! Under `legacy-0.4.10`, the old depth-threshold algorithm is used instead
|
||||
//! (see `versions::legacy_0_4_10` module).
|
||||
//! Under contract version `legacy-0.4.10` the depth-threshold algorithm in
|
||||
//! `versions::legacy_0_4_10` runs instead.
|
||||
mod versions;
|
||||
use crate::types::output::{ListDirContent, ListDirOutput};
|
||||
#[allow(unused_imports)]
|
||||
@@ -42,20 +36,15 @@ pub struct ListDirInput {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListDirParams {
|
||||
/// BFS expansion stops when this budget would be exceeded.
|
||||
/// Defaults to `DEFAULT_MAX_OUTPUT_CHARS` (10,000) to match the Python
|
||||
/// Defaults to `DEFAULT_MAX_OUTPUT_CHARS`.
|
||||
pub max_output_chars: Option<usize>,
|
||||
}
|
||||
crate::register_resource!("kigi", "ListDir", ListDirParams);
|
||||
/// Exact historical invalid-directory message for `list_dir` in legacy-0.4.10.
|
||||
///
|
||||
/// Historical fixture captured from an earlier (0.4.10) revision of this tool.
|
||||
///
|
||||
/// Historical 0.4.10 collapsed nonexistent paths, file paths, and other
|
||||
/// invalid-directory failures into the same generic message.
|
||||
/// Contract version `legacy-0.4.10` collapses nonexistent paths, file paths and
|
||||
/// every other invalid-directory failure into this one generic message.
|
||||
fn render_legacy_list_dir_error(path: &Path) -> String {
|
||||
format!("Error: {} is not a valid directory", path.display())
|
||||
}
|
||||
/// Internal version discriminant for list_dir.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ListDirVersion {
|
||||
Current,
|
||||
@@ -72,9 +61,8 @@ impl ListDirVersion {
|
||||
self == Self::Legacy0_4_10
|
||||
}
|
||||
}
|
||||
/// Compute the path shown in the list_dir tool result header.
|
||||
/// Special-cases `list_dir(".")`, `list_dir("")`, and `list_dir("./foo")` so the
|
||||
/// output does not contain ugly "/./" components (e.g. `/workspace/./`).
|
||||
/// result header does not contain "/./" components (e.g. `/workspace/./`).
|
||||
fn compute_display_path(display_base: &std::path::Path, target: &str) -> std::path::PathBuf {
|
||||
let t = target.trim().trim_start_matches("./");
|
||||
if t.is_empty() || t == "." {
|
||||
@@ -85,7 +73,6 @@ fn compute_display_path(display_base: &std::path::Path, target: &str) -> std::pa
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ListDirTool;
|
||||
/// Default character budget for directory listing output.
|
||||
/// Matches the Python SWE tool's `lim_characters` default.
|
||||
const DEFAULT_MAX_OUTPUT_CHARS: usize = 10_000;
|
||||
/// Show top-N extension buckets in collapsed-directory summary lines.
|
||||
@@ -109,8 +96,8 @@ fn root_truncation_notice(renderer: Option<&TemplateRenderer>) -> String {
|
||||
const MAX_GLOBAL_ITEMS: usize = 100_000;
|
||||
/// Cap on depth-1 seed entries so a pathological flat root (millions of direct
|
||||
/// children) cannot fully materialize into `DirNode` before the char budget truncates.
|
||||
/// Independent in role from `MAX_GLOBAL_ITEMS`, but pinned equal to it (see guard below) so
|
||||
/// the cutoff notice's shared count stays correct whichever cap triggers truncation.
|
||||
/// Independent in role from `MAX_GLOBAL_ITEMS`, but pinned equal to it (see the guard
|
||||
/// below) so the cutoff notice's shared count stays correct whichever cap triggers.
|
||||
const MAX_SEED_ITEMS: usize = 100_000;
|
||||
const _: () = assert!(MAX_SEED_ITEMS == MAX_GLOBAL_ITEMS);
|
||||
#[derive(Debug, Default)]
|
||||
@@ -222,7 +209,6 @@ impl DirNode {
|
||||
self.subtree.add_ext(&ext);
|
||||
}
|
||||
}
|
||||
/// Sort files and subdirs case-insensitively, recursively.
|
||||
fn sort_recursive(&mut self) {
|
||||
self.files.sort_by_key(|a| a.to_ascii_lowercase());
|
||||
self.subdirs.sort_by_key(|a| a.to_ascii_lowercase());
|
||||
@@ -254,7 +240,6 @@ impl DirNode {
|
||||
}
|
||||
(self.depth + 1) * 2 + s.len() + 1
|
||||
}
|
||||
/// Render this node's children, recursing into expanded child nodes.
|
||||
fn render_expanded(&self, top_k: usize) -> String {
|
||||
let mut out = String::new();
|
||||
for name in self.all_subitems_sorted() {
|
||||
@@ -266,7 +251,7 @@ impl DirNode {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Render subtree: expanded nodes show children, collapsed show summary.
|
||||
/// Expanded nodes render their children; collapsed nodes render a summary line.
|
||||
fn render_subtree(&self, top_k: usize) -> String {
|
||||
if self.is_expanded {
|
||||
return self.render_expanded(top_k);
|
||||
@@ -428,7 +413,6 @@ fn navigate_mut<'a>(root: &'a mut DirNode, path: &[String]) -> Option<&'a mut Di
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
/// Render as many root items as fit within budget, then append truncation notice.
|
||||
fn render_truncated_root(root: &DirNode, max_chars: usize, top_k: usize, notice: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut remaining = max_chars;
|
||||
|
||||
+8
-52
@@ -1,16 +1,11 @@
|
||||
//! Legacy (0.4.10) depth-threshold directory rendering.
|
||||
//!
|
||||
//! Extracted from an earlier revision of the codebase. The current renderer uses
|
||||
//! BFS character-budget expansion; this module preserves the old depth-based
|
||||
//! summarization algorithm for `contract_version = "legacy-0.4.10"`.
|
||||
//! The depth-based summarization algorithm, served for
|
||||
//! `contract_version = "legacy-0.4.10"`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROOT_SUMMARIZATION_THRESHOLD: usize = 1500;
|
||||
const SUBDIR_SUMMARIZATION_THRESHOLD: usize = 15;
|
||||
const TOP_K_EXTENSIONS_TO_RENDER: usize = 3;
|
||||
@@ -46,10 +41,6 @@ impl RenderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Accumulator + helpers
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DirAccum {
|
||||
total_files: usize,
|
||||
@@ -115,10 +106,6 @@ fn filename(path: &Path) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tree structures
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChildEntry {
|
||||
is_dir: bool,
|
||||
@@ -164,10 +151,6 @@ fn get_or_init_dir<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Collection + rendering
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn collect(root_path: &Path, walker: ignore::Walk, cfg: &RenderConfig) -> Collected {
|
||||
let mut dirs: HashMap<PathBuf, DirectoryView> = HashMap::new();
|
||||
for directory_entry in walker {
|
||||
@@ -302,13 +285,7 @@ fn render_with_fallback(root: &Path, collected: &Collected, cfg: &RenderConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Public entry point
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render a directory listing using the legacy (0.4.10) depth-threshold algorithm.
|
||||
///
|
||||
/// Returns the body text (without the root path header line).
|
||||
/// Returns the body text without the root path header line.
|
||||
pub(crate) fn render_legacy(root: &Path, max_output_bytes: usize) -> String {
|
||||
let cfg = RenderConfig {
|
||||
max_output_bytes,
|
||||
@@ -327,16 +304,11 @@ pub(crate) fn render_legacy(root: &Path, max_output_bytes: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests — fixture-based historical verification
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a reference directory tree for fixture comparison.
|
||||
fn create_fixture_tree(root: &std::path::Path) {
|
||||
// src/
|
||||
// main.rs
|
||||
@@ -361,22 +333,15 @@ mod tests {
|
||||
std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
|
||||
}
|
||||
|
||||
/// The legacy depth-based renderer expands small directories fully,
|
||||
/// showing individual files with indentation per depth level.
|
||||
/// Archived-output fixture test: asserts exact string equality against
|
||||
/// the known output of the earlier depth-threshold algorithm.
|
||||
///
|
||||
/// Fixture captured from `render_legacy()` on the reference tree defined
|
||||
/// by `create_fixture_tree()`. If this test fails after a change to the
|
||||
/// legacy renderer, the change has drifted from historical behavior.
|
||||
/// Guards the legacy renderer against drift: a failure means output no
|
||||
/// longer matches the archived depth-threshold behavior.
|
||||
#[test]
|
||||
fn legacy_renders_small_tree_exact_fixture() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
create_fixture_tree(tmp.path());
|
||||
let body = render_legacy(tmp.path(), 40_000);
|
||||
|
||||
// Exact archived output from the depth-threshold algorithm.
|
||||
// Root files listed alphabetically, src/ expanded (< 15 children),
|
||||
// Root files alphabetical, src/ expanded (< 15 children),
|
||||
// util/ summarized (depth >= 2), tests/ expanded.
|
||||
let expected = " - Cargo.toml\n - README.md\n - src/\n - lib.rs\n - main.rs\n - util/\n [1 file in subtree: 1 *.rs]\n - tests/\n - test_main.rs";
|
||||
|
||||
@@ -387,8 +352,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty directories should produce empty output (no "no children found"
|
||||
/// — that is added by the caller in mod.rs, not by the renderer).
|
||||
/// The renderer emits nothing for an empty dir; the "no children found"
|
||||
/// line is added by the caller in mod.rs, not here.
|
||||
#[test]
|
||||
fn legacy_empty_directory_returns_empty_string() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -399,9 +364,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The depth-based algorithm summarizes directories when child count
|
||||
/// exceeds the threshold (15 for subdirs by default). Verify that a
|
||||
/// large directory gets a summary line instead of full expansion.
|
||||
#[test]
|
||||
fn legacy_summarizes_large_subdirectory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -413,7 +375,6 @@ mod tests {
|
||||
}
|
||||
let body = render_legacy(tmp.path(), 40_000);
|
||||
|
||||
// Should show a summary line with file count and extension breakdown.
|
||||
assert!(
|
||||
body.contains("files in subtree") || body.contains("file in subtree"),
|
||||
"large dir should be summarized: {body}"
|
||||
@@ -424,17 +385,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify structural equivalence: the depth-based renderer produces
|
||||
/// lines with consistent 2-space indentation matching the historical
|
||||
/// algorithm's output pattern.
|
||||
#[test]
|
||||
fn legacy_indentation_matches_historical_pattern() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
create_fixture_tree(tmp.path());
|
||||
let body = render_legacy(tmp.path(), 40_000);
|
||||
|
||||
// Every line should start with some number of " " pairs followed by "- "
|
||||
// or be a summary line (starts with spaces + "[").
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
let indent_chars = line.len() - trimmed.len();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Version-specific behavior modules for `list_dir`.
|
||||
//!
|
||||
//! - `legacy_0_4_10`: depth-threshold rendering + generic error messages
|
||||
//! - Current behavior remains in `list_dir/mod.rs` (BFS budget rendering
|
||||
//! + structured error variants).
|
||||
//! Version-specific behavior modules for `list_dir`. Current behavior (BFS
|
||||
//! budget rendering, structured error variants) lives in `list_dir/mod.rs`;
|
||||
//! `legacy_0_4_10` holds depth-threshold rendering and generic error messages.
|
||||
|
||||
pub(crate) mod legacy_0_4_10;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! `lsp` tool - code intelligence via language servers.
|
||||
//!
|
||||
//! Implementation is in `implementations::lsp`. This module provides the
|
||||
//! `LspTool` (Tool trait impl) under the `Kigi` namespace.
|
||||
//! The `Tool` trait wrapper; the backend dispatch lives in `implementations::lsp`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
//! New-architecture tool implementations (NewTool trait).
|
||||
//!
|
||||
//! Each sub-module here contains a tool that implements `NewTool` instead
|
||||
//! of the old `Tool` trait. During migration, old implementations live in
|
||||
//! `implementations/<tool>/` and new implementations live in
|
||||
//! `implementations/kigi/<tool>/`.
|
||||
//!
|
||||
//! The [`register_all()`] function is the single entry-point for wiring up
|
||||
//! the standard toolset. It inserts shared resources (`Terminal`,
|
||||
//! `AvailableSkills`, `BashParams`) and registers every built-in tool.
|
||||
//! Tool implementations built on the `NewTool` trait; the sibling
|
||||
//! `implementations/<tool>/` modules hold the `Tool`-trait counterparts.
|
||||
pub mod ask_user_question;
|
||||
pub mod bash;
|
||||
#[path = "deploy_app_stub.rs"]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use super::types::{BATCH_TRUNCATION_LIMIT, BUFFER_CAP_BYTES, LINE_TRUNCATION_LIMIT};
|
||||
use crate::util::floor_char_boundary;
|
||||
|
||||
/// Processes raw stdout chunks into complete lines.
|
||||
///
|
||||
/// Buffers partial lines, splits on `\n`, truncates individual lines at
|
||||
/// `LINE_TRUNCATION_LIMIT` chars, and caps the internal buffer at `BUFFER_CAP_BYTES`.
|
||||
/// Splits raw stdout chunks into complete lines, buffering partial lines
|
||||
/// across chunks. Individual lines are truncated at `LINE_TRUNCATION_LIMIT`
|
||||
/// chars and the buffer is capped at `BUFFER_CAP_BYTES`, keeping the tail.
|
||||
#[derive(Default)]
|
||||
pub struct LineProcessor {
|
||||
buffer: Vec<u8>,
|
||||
@@ -15,11 +14,9 @@ impl LineProcessor {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Push a raw stdout chunk. Returns any complete lines extracted.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> Vec<String> {
|
||||
self.buffer.extend_from_slice(chunk);
|
||||
|
||||
// Cap buffer at BUFFER_CAP_BYTES (keep the tail).
|
||||
if self.buffer.len() > BUFFER_CAP_BYTES {
|
||||
let start = self.buffer.len() - BUFFER_CAP_BYTES;
|
||||
self.buffer = self.buffer[start..].to_vec();
|
||||
@@ -37,7 +34,6 @@ impl LineProcessor {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Flush any remaining partial line from the buffer.
|
||||
pub fn flush(&mut self) -> Option<String> {
|
||||
if self.buffer.is_empty() {
|
||||
return None;
|
||||
@@ -60,7 +56,6 @@ fn truncate_line(line: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch multiple lines into a single event string, truncating at `BATCH_TRUNCATION_LIMIT`.
|
||||
pub fn batch_lines(lines: &[String]) -> String {
|
||||
let joined = lines.join("\n");
|
||||
if joined.len() > BATCH_TRUNCATION_LIMIT {
|
||||
@@ -93,9 +88,6 @@ pub fn wrap_monitor_event(description: &str, event_text: &str, task_id: &str) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Quotes and newlines in the model-supplied description are neutralized
|
||||
/// before embedding — they would otherwise break the attribute quoting
|
||||
/// (`" task_id="` anchor) or the single-line opening tag (`>\n` anchor).
|
||||
#[test]
|
||||
fn wrap_sanitizes_description() {
|
||||
let wrapped = wrap_monitor_event("watch \"prod\"\nlogs", "line", "t-1");
|
||||
@@ -150,7 +142,6 @@ mod tests {
|
||||
#[test]
|
||||
fn buffer_cap_enforced() {
|
||||
let mut proc = LineProcessor::new();
|
||||
// Push more than BUFFER_CAP_BYTES without newlines
|
||||
let big = vec![b'a'; BUFFER_CAP_BYTES + 1000];
|
||||
proc.push(&big);
|
||||
assert!(proc.buffer.len() <= BUFFER_CAP_BYTES);
|
||||
@@ -187,17 +178,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn truncate_line_multibyte_no_panic() {
|
||||
// 3-byte UTF-8 chars — truncation boundary may land mid-char
|
||||
let line = "\u{4e16}\u{754c}".repeat(200); // CJK chars, 3 bytes each
|
||||
// 3-byte UTF-8 chars — the truncation boundary may land mid-char
|
||||
let line = "\u{4e16}\u{754c}".repeat(200);
|
||||
let truncated = truncate_line(&line);
|
||||
assert!(truncated.ends_with("...(truncated)"));
|
||||
// Verify the result is valid UTF-8 (would panic if not)
|
||||
let _ = truncated.as_bytes();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_line_emoji_no_panic() {
|
||||
// 4-byte emoji chars
|
||||
let line = "\u{1F600}".repeat(200);
|
||||
let truncated = truncate_line(&line);
|
||||
assert!(truncated.ends_with("...(truncated)"));
|
||||
|
||||
@@ -23,7 +23,7 @@ impl TokenBucket {
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to consume one token. Returns true if a token was available.
|
||||
/// Consumes one token, refilling first. Returns false when the bucket is empty.
|
||||
pub fn try_consume(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_refill);
|
||||
@@ -41,10 +41,9 @@ impl TokenBucket {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks rate-limit suppression state and auto-kill logic.
|
||||
///
|
||||
/// Used alongside `TokenBucket` to detect sustained overload and generate
|
||||
/// catch-up notices when the rate subsides.
|
||||
/// Tracks suppression state next to a `TokenBucket`: detects sustained
|
||||
/// overload, generates catch-up notices when the rate subsides, and
|
||||
/// triggers auto-kill.
|
||||
#[derive(Default)]
|
||||
pub struct SuppressionTracker {
|
||||
pub suppressed_count: u64,
|
||||
@@ -118,7 +117,6 @@ impl SuppressionTracker {
|
||||
self.suppression_start = Some(Instant::now());
|
||||
}
|
||||
|
||||
// Check auto-kill threshold.
|
||||
if let Some(start) = self.suppression_start {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > Duration::from_millis(AUTO_KILL_THRESHOLD_MS) {
|
||||
@@ -161,7 +159,6 @@ impl MonitorRateLimiter {
|
||||
self
|
||||
}
|
||||
|
||||
/// Process an event. Returns the rate limit decision.
|
||||
pub fn process_event(&mut self, description: &str) -> RateLimitOutcome {
|
||||
let available = self.bucket.try_consume();
|
||||
self.suppression.process(available, description)
|
||||
@@ -187,20 +184,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bucket_refills_after_interval() {
|
||||
let mut bucket = TokenBucket::new(10, 50); // 50ms for test speed
|
||||
let mut bucket = TokenBucket::new(10, 50);
|
||||
for _ in 0..10 {
|
||||
bucket.try_consume();
|
||||
}
|
||||
assert!(!bucket.try_consume());
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
assert!(bucket.try_consume()); // one token refilled
|
||||
assert!(bucket.try_consume());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_does_not_exceed_capacity() {
|
||||
let mut bucket = TokenBucket::new(3, 50);
|
||||
std::thread::sleep(Duration::from_millis(200)); // enough for many refills
|
||||
// Should be capped at 3
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(bucket.try_consume());
|
||||
assert!(bucket.try_consume());
|
||||
assert!(bucket.try_consume());
|
||||
@@ -218,13 +214,11 @@ mod tests {
|
||||
#[test]
|
||||
fn catch_up_notice_on_recovery() {
|
||||
let mut tracker = SuppressionTracker::new();
|
||||
// Suppress some events
|
||||
tracker.process(false, "test");
|
||||
tracker.process(false, "test");
|
||||
tracker.process(false, "test");
|
||||
assert_eq!(tracker.suppressed_count, 3);
|
||||
|
||||
// Now a token is available
|
||||
let outcome = tracker.process(true, "test");
|
||||
match outcome {
|
||||
RateLimitOutcome::Allowed { catch_up_notice } => {
|
||||
@@ -259,14 +253,12 @@ mod tests {
|
||||
#[test]
|
||||
fn combined_rate_limiter() {
|
||||
let mut rl = MonitorRateLimiter::new(3, 2000);
|
||||
// First 3 events pass
|
||||
for _ in 0..3 {
|
||||
assert!(matches!(
|
||||
rl.process_event("test"),
|
||||
RateLimitOutcome::Allowed { .. }
|
||||
));
|
||||
}
|
||||
// 4th is suppressed
|
||||
assert!(matches!(
|
||||
rl.process_event("test"),
|
||||
RateLimitOutcome::Suppressed
|
||||
|
||||
@@ -119,7 +119,8 @@ impl kigi_tool_runtime::Tool for MonitorTool {
|
||||
"1".to_string(),
|
||||
)]),
|
||||
timeout: if resolved_timeout == 0 {
|
||||
Duration::from_secs(86400 * 365) // long-running (until kill or session end)
|
||||
// long-running (until kill or session end)
|
||||
Duration::from_secs(86400 * 365)
|
||||
} else {
|
||||
Duration::from_millis(resolved_timeout)
|
||||
},
|
||||
@@ -160,8 +161,6 @@ impl kigi_tool_runtime::Tool for MonitorTool {
|
||||
});
|
||||
|
||||
// Spawn the stdout processing pipeline.
|
||||
// Reads the output file, processes lines through the rate limiter,
|
||||
// and emits MonitorEvent notifications.
|
||||
let pipeline_task_id = task_id.clone();
|
||||
let pipeline_description = description.clone();
|
||||
// Weak handle: the pipeline must not keep the session's terminal backend
|
||||
@@ -188,7 +187,8 @@ impl kigi_tool_runtime::Tool for MonitorTool {
|
||||
&pipeline_notif,
|
||||
&pipeline_output_file,
|
||||
pipeline_kill_name,
|
||||
0, // fresh pipeline — read from start
|
||||
// fresh pipeline — read from start
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -263,7 +263,6 @@ pub(crate) async fn run_monitor_pipeline(
|
||||
break;
|
||||
};
|
||||
|
||||
// Check if the task is still running.
|
||||
let snapshot = terminal.get_task(task_id).await;
|
||||
let completed = snapshot.is_none() || snapshot.as_ref().is_some_and(|s| s.completed);
|
||||
|
||||
@@ -275,7 +274,6 @@ pub(crate) async fn run_monitor_pipeline(
|
||||
}
|
||||
let owner_session_id = snapshot_owner.or_else(|| last_owner.clone());
|
||||
|
||||
// Read new output from the file.
|
||||
let new_bytes = read_new_bytes(output_file, &mut last_read_offset).await;
|
||||
if !new_bytes.is_empty() {
|
||||
let lines = line_processor.push(&new_bytes);
|
||||
@@ -294,7 +292,6 @@ pub(crate) async fn run_monitor_pipeline(
|
||||
}
|
||||
|
||||
if completed {
|
||||
// Flush any remaining partial line.
|
||||
if let Some(remaining) = line_processor.flush() {
|
||||
process_event(
|
||||
task_id,
|
||||
@@ -349,7 +346,8 @@ async fn read_new_bytes(path: &std::path::Path, offset: &mut u64) -> Vec<u8> {
|
||||
}
|
||||
|
||||
let to_read = (file_len - *offset) as usize;
|
||||
let mut buf = vec![0u8; to_read.min(1024 * 1024)]; // cap single read at 1MB
|
||||
// cap single read at 1MB
|
||||
let mut buf = vec![0u8; to_read.min(1024 * 1024)];
|
||||
let Ok(n) = file.read(&mut buf).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -471,7 +469,6 @@ mod tests {
|
||||
.await;
|
||||
});
|
||||
|
||||
// The monitor is running and visible.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
backend.get_task(&task_id).await.is_some(),
|
||||
|
||||
@@ -4,8 +4,8 @@ pub const LINE_TRUNCATION_LIMIT: usize = 500;
|
||||
/// Max characters per batched event (multiple lines joined).
|
||||
pub const BATCH_TRUNCATION_LIMIT: usize = 3_000;
|
||||
|
||||
/// Raw stdout buffer cap in bytes.
|
||||
pub const BUFFER_CAP_BYTES: usize = 1_048_576; // 1 MB
|
||||
/// Raw stdout buffer cap. 1 MB.
|
||||
pub const BUFFER_CAP_BYTES: usize = 1_048_576;
|
||||
|
||||
/// Debounce window for batching concurrent stdout lines (ms).
|
||||
pub const DEBOUNCE_MS: u64 = 200;
|
||||
@@ -21,10 +21,10 @@ pub const AUTO_KILL_THRESHOLD_MS: u64 = 30_000;
|
||||
|
||||
/// Default monitor timeout (non-persistent). 10 hours to avoid short
|
||||
/// unexpected cutoffs for monitors the model starts without an explicit deadline.
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 36_000_000;
|
||||
|
||||
/// Maximum monitor timeout.
|
||||
pub const MAX_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
|
||||
/// Maximum monitor timeout (10 hours).
|
||||
pub const MAX_TIMEOUT_MS: u64 = 36_000_000;
|
||||
|
||||
/// Max result size for the tool_result response.
|
||||
pub const MAX_RESULT_SIZE_CHARS: usize = 10_000;
|
||||
@@ -83,7 +83,6 @@ pub enum MonitorError {
|
||||
}
|
||||
|
||||
impl MonitorInput {
|
||||
/// Validate input constraints.
|
||||
pub fn validate(&self) -> Result<(), MonitorError> {
|
||||
let persistent = self.persistent.unwrap_or(false);
|
||||
if let Some(timeout) = self.timeout_ms
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! ReadFile — new-architecture implementation.
|
||||
//!
|
||||
//! Reuses the core logic (`extract_file_content_lines`, `bytes_to_metadata`,
|
||||
//! constants) from the old `implementations::read_file` module.
|
||||
//! State:
|
||||
//! - Notifications emitted via `NotificationHandle` from Resources.
|
||||
//!
|
||||
//! Reminders are NOT implemented here (Phase 5).
|
||||
//! ReadFile tool. Shares core helpers (`extract_file_content_lines`,
|
||||
//! `bytes_to_metadata`, constants) with `implementations::read_file`.
|
||||
use crate::implementations::read_file::{
|
||||
handle_pdf, is_pdf_file, raw_text_to_file_content, run_document_extraction,
|
||||
};
|
||||
@@ -23,7 +17,6 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
use std::sync::LazyLock;
|
||||
mod versions;
|
||||
use crate::types::schema::KigiIntegerSchema;
|
||||
/// Configuration for the ReadFile tool, stored as `Params<ReadFileParams>` in Resources.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadFileParams {
|
||||
@@ -91,15 +84,11 @@ async fn handle_pptx(
|
||||
.await
|
||||
}
|
||||
/// Extract text from a PPTX file (zip + DrawingML text runs).
|
||||
///
|
||||
/// Returns line-numbered text via the shared `raw_text_to_file_content`
|
||||
/// helper.
|
||||
fn extract_pptx_text(file_bytes: Vec<u8>) -> Result<ReadFileOutput, String> {
|
||||
let text = crate::implementations::read_file::pptx::extract_pptx_text_from_bytes(&file_bytes)
|
||||
.map_err(|e| format!("Failed to extract text from PPTX: {e}"))?;
|
||||
Ok(raw_text_to_file_content(text))
|
||||
}
|
||||
/// Description for default toolset (full/non-concise)
|
||||
pub(crate) const DESCRIPTION_FULL: &str = r#"Read a file.
|
||||
|
||||
Usage:
|
||||
@@ -180,7 +169,6 @@ pub struct ExtractedContent {
|
||||
pub content: String,
|
||||
/// Concise format: identical to content (kept for backward compatibility)
|
||||
pub content_concise: String,
|
||||
/// Raw unformatted content
|
||||
pub raw_output: String,
|
||||
/// Base64 images captured per-line before truncation. Plumbed through
|
||||
/// `FileContent.extracted_images` and turned into multimodal
|
||||
@@ -540,11 +528,6 @@ pub(crate) async fn run_read_file(
|
||||
extracted_images,
|
||||
}))
|
||||
}
|
||||
/// New-architecture `ReadFile` tool.
|
||||
///
|
||||
/// Params: `()` — no per-tool configuration.
|
||||
///
|
||||
/// Notifications: Emits `FileRead` via `NotificationHandle`.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ReadFileTool;
|
||||
impl crate::types::tool_metadata::ToolMetadata for ReadFileTool {
|
||||
@@ -666,7 +649,6 @@ mod tests {
|
||||
use crate::types::tool_metadata::test_ctx;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
/// Set up Resources with real filesystem for tests.
|
||||
fn test_resources(cwd: &std::path::Path) -> Resources {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(cwd.to_path_buf()));
|
||||
@@ -1014,10 +996,9 @@ mod tests {
|
||||
assert_eq!(extracted.content_concise, "1→1\n2\n3\n");
|
||||
assert_eq!(extracted.raw_output, "1\n2\r\n3\n");
|
||||
}
|
||||
/// Regression: a long single-line base64 URI used to be cut
|
||||
/// mid-payload by the (since-removed) per-line clip and re-emitted as
|
||||
/// a corrupt vision token. Pin that the full payload is captured
|
||||
/// byte-equal.
|
||||
/// Regression: a long single-line base64 URI must be captured
|
||||
/// byte-equal, not clipped mid-payload and re-emitted as a corrupt
|
||||
/// vision token.
|
||||
#[test]
|
||||
fn extract_captures_long_inline_base64_image_before_truncation() {
|
||||
let payload = "A".repeat(50_000);
|
||||
@@ -1624,8 +1605,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
}
|
||||
}
|
||||
/// Wrapper-level user-visible message: prefix matches the caller's
|
||||
/// `"Could not embed image in conversation: ..."` and the legacy
|
||||
/// "Image too large to embed..." is no longer used.
|
||||
/// `"Could not embed image in conversation: ..."`, not the legacy
|
||||
/// "Image too large to embed..." form.
|
||||
#[test]
|
||||
fn compress_oversized_garbage_user_message_is_non_legacy() {
|
||||
let bytes = vec![0u8; MAX_IMAGE_PAYLOAD_BYTES + 4096];
|
||||
@@ -2122,9 +2103,9 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
||||
}
|
||||
/// Regression for the "death spiral" incident: a single-line
|
||||
/// ~49.5KB JSON payload must be readable in full with default config.
|
||||
/// The old 2000-char per-line clip made such files unreadable by
|
||||
/// construction (bash output and MCP results are byte-capped too), so the
|
||||
/// model could never load a payload it needed to re-emit as tool input.
|
||||
/// A per-line clip would make such files unreadable by construction
|
||||
/// (bash output and MCP results are byte-capped too), so the model
|
||||
/// could never load a payload it needed to re-emit as tool input.
|
||||
#[tokio::test]
|
||||
async fn single_line_payload_reads_in_full_by_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+6
-16
@@ -1,26 +1,16 @@
|
||||
//! Legacy (0.4.10) behavior for `read_file`.
|
||||
//!
|
||||
//! Centralizes all version-specific policy decisions for legacy-0.4.10:
|
||||
//! - Generic error message for all filesystem failures (no structured variants)
|
||||
//! - No gitignore enforcement (gitignored files are readable)
|
||||
//!
|
||||
//! The main `run_read_file()` flow calls these helpers to make version-specific
|
||||
//! decisions. The execution path stays in `mod.rs`; the policy lives here.
|
||||
//! Legacy (0.4.10) policy for `read_file`: a generic error message for every
|
||||
//! filesystem failure (no structured variants) and no gitignore enforcement.
|
||||
//! The execution path stays in `mod.rs`; only version-specific policy lives here.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Exact historical read failure message for `read_file` in legacy-0.4.10.
|
||||
///
|
||||
/// Captured from the historical 0.4.10 implementation.
|
||||
///
|
||||
/// Historical 0.4.10 collapsed filesystem read failures (missing file,
|
||||
/// directory path, permission denied, etc.) into the same generic message
|
||||
/// without appending OS error detail.
|
||||
/// Historical 0.4.10 collapsed all filesystem read failures (missing file,
|
||||
/// directory path, permission denied) into this generic message without
|
||||
/// appending OS error detail.
|
||||
pub(crate) fn render_read_error(path: &Path) -> String {
|
||||
format!("Failed to read file: {}", path.display())
|
||||
}
|
||||
|
||||
/// Legacy 0.4.10 does not enforce gitignore — gitignored files are readable.
|
||||
pub(crate) fn allows_gitignored_reads() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
//! Version-specific behavior modules for `read_file`.
|
||||
//!
|
||||
//! - `legacy_0_4_10`: generic error messages, no gitignore enforcement,
|
||||
//! legacy marker for reminder suppression.
|
||||
//! - Current behavior remains in `read_file/mod.rs` (structured error
|
||||
//! variants, gitignore enforcement, confusable reminders).
|
||||
//! Version-specific behavior modules for `read_file`. Legacy policy lives in
|
||||
//! `legacy_0_4_10`; current behavior stays in `read_file/mod.rs`.
|
||||
|
||||
pub(crate) mod legacy_0_4_10;
|
||||
|
||||
@@ -13,9 +13,8 @@ use super::types::{ScheduledTask, SchedulerCommand, SchedulerError, SchedulerSta
|
||||
|
||||
const MAX_SCHEDULED_TASKS: usize = 50;
|
||||
|
||||
/// Build a `ScheduledTaskCreated` payload from a task. Shared between the
|
||||
/// live `SchedulerCommand::Create` path and the post-restore re-announce so
|
||||
/// the wire format stays in lockstep.
|
||||
/// Shared between the live `SchedulerCommand::Create` path and the post-restore
|
||||
/// re-announce so the wire format stays in lockstep.
|
||||
fn task_created_payload(task: &ScheduledTask) -> ScheduledTaskCreated {
|
||||
ScheduledTaskCreated {
|
||||
task_id: task.id.clone(),
|
||||
@@ -450,14 +449,12 @@ mod tests {
|
||||
.unwrap();
|
||||
reply_rx.await.unwrap().unwrap();
|
||||
|
||||
// Drain ScheduledTaskCreated.
|
||||
let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv())
|
||||
.await
|
||||
.expect("created")
|
||||
.expect("channel open");
|
||||
assert!(matches!(notif, ToolNotification::ScheduledTaskCreated(_)));
|
||||
|
||||
// First fire.
|
||||
let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv())
|
||||
.await
|
||||
.expect("first fire")
|
||||
@@ -600,7 +597,6 @@ mod tests {
|
||||
}
|
||||
assert!(notif_rx.try_recv().is_err());
|
||||
|
||||
// All fired missed one-shots are pruned from state.
|
||||
let res = shared.lock().await;
|
||||
let remaining = res
|
||||
.get::<State<SchedulerState>>()
|
||||
@@ -643,7 +639,6 @@ mod tests {
|
||||
cancel_token.cancel();
|
||||
handle.await.expect("actor should complete");
|
||||
|
||||
// Collect all ScheduledTaskRemoved notifications.
|
||||
let mut removed_ids = Vec::new();
|
||||
while let Ok(notif) = notif_rx.try_recv() {
|
||||
if let ToolNotification::ScheduledTaskRemoved(r) = notif {
|
||||
|
||||
@@ -13,15 +13,12 @@ pub use kigi_tools_api::slash_commands::{
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SchedulerCreateInput {
|
||||
/// Interval string: "5m", "2h", "1d", etc.
|
||||
#[schemars(description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\"")]
|
||||
pub interval: String,
|
||||
|
||||
/// The prompt to run on each fire.
|
||||
#[schemars(description = "The prompt text to execute on each scheduled fire")]
|
||||
pub prompt: String,
|
||||
|
||||
/// Whether the task recurs. Default true.
|
||||
#[serde(
|
||||
default = "default_true",
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
@@ -31,7 +28,6 @@ pub struct SchedulerCreateInput {
|
||||
)]
|
||||
pub recurring: bool,
|
||||
|
||||
/// Whether the task persists across sessions. Default false (session-only).
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
@@ -39,9 +35,8 @@ pub struct SchedulerCreateInput {
|
||||
#[schemars(description = "Whether the task persists across sessions. Default: false")]
|
||||
pub durable: Option<bool>,
|
||||
|
||||
/// Whether to fire immediately on creation. Default false (wait for the
|
||||
/// first interval — a "scheduled" task should not run on creation unless
|
||||
/// explicitly asked to).
|
||||
/// Default false: a "scheduled" task should not run on creation unless
|
||||
/// explicitly asked to.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
|
||||
@@ -10,7 +10,6 @@ pub const SCHEDULER_DELETE_TOOL_NAME: &str = "scheduler_delete";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SchedulerDeleteInput {
|
||||
/// The scheduled task ID to cancel.
|
||||
#[schemars(description = "The task ID to cancel (from scheduler_create output)")]
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ pub enum SchedulerError {
|
||||
TaskLimitReached(usize),
|
||||
}
|
||||
|
||||
/// A single scheduled recurring or one-shot task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledTask {
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
//! SearchReplace tool implementation.
|
||||
//!
|
||||
//! This tool performs exact string replacements in files with support for:
|
||||
//! - Exact string replacement (find/replace)
|
||||
//! - New file creation (when `old_string` is empty)
|
||||
//! - Replace all mode (`replace_all: true`)
|
||||
//! - Read-before-edit validation (non-concise mode)
|
||||
//! - External modification detection
|
||||
//! String-edit and confusable-normalized matching helpers for the
|
||||
//! SearchReplace tool.
|
||||
|
||||
use crate::types::output::SearchReplaceEditDetail;
|
||||
|
||||
// ============================================================================
|
||||
// Shared string-edit helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Render a snippet of the file with line numbers around the edit.
|
||||
pub(crate) fn render_snippet(
|
||||
new_text: &str,
|
||||
@@ -60,7 +50,6 @@ pub(crate) struct LineRange {
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
/// Compute the line range of the inserted text in the text
|
||||
pub(crate) fn compute_line_range(text: &str, start_pos: usize, inserted_text: &str) -> LineRange {
|
||||
let start_line = text[..start_pos].matches('\n').count();
|
||||
let lines_in_inserted = inserted_text.split_inclusive('\n').count().max(1);
|
||||
@@ -93,7 +82,6 @@ pub(crate) fn replace_using_positions(
|
||||
(new_text, new_positions)
|
||||
}
|
||||
|
||||
/// Build edit details for each replacement.
|
||||
pub(crate) fn build_edit_details(
|
||||
new_text: &str,
|
||||
old_string: &str,
|
||||
@@ -107,7 +95,6 @@ pub(crate) fn build_edit_details(
|
||||
render_snippet(new_text, new_string, start_pos, context_lines);
|
||||
let line_range_new = compute_line_range(new_text, start_pos, new_string);
|
||||
// Extract the leading text on the line before the match starts.
|
||||
// This is the text between the last '\n' before start_pos and start_pos itself.
|
||||
let line_start = new_text[..start_pos]
|
||||
.rfind('\n')
|
||||
.map(|i| i + 1)
|
||||
@@ -127,10 +114,6 @@ pub(crate) fn build_edit_details(
|
||||
details
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Normalized (confusable-aware) matching helpers
|
||||
// ============================================================================
|
||||
|
||||
/// A single match found via confusable-normalized comparison, expressed in
|
||||
/// the original text's byte coordinates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -184,8 +167,6 @@ pub(crate) fn find_normalized_match_positions(text: &str, pattern: &str) -> Norm
|
||||
return NormalizedMatchResult::NoMatch;
|
||||
}
|
||||
|
||||
// Collect all non-overlapping matches in normalized space and validate
|
||||
// each candidate via roundtrip check.
|
||||
let mut validated = Vec::new();
|
||||
let mut had_rejected_candidates = false;
|
||||
|
||||
@@ -371,8 +352,6 @@ mod tests {
|
||||
assert_eq!(new_positions, vec![0, 10]);
|
||||
}
|
||||
|
||||
// ── Normalized matching helpers ─────────────────────────────────────
|
||||
|
||||
fn unwrap_matches(result: NormalizedMatchResult) -> Vec<NormalizedMatch> {
|
||||
match result {
|
||||
NormalizedMatchResult::Matches(m) => m,
|
||||
@@ -444,8 +423,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Partial-expansion rejection ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn partial_expansion_dash_inside_em_dash_rejected() {
|
||||
let text = "\u{2014}";
|
||||
@@ -493,8 +470,6 @@ mod tests {
|
||||
assert_eq!(matches.len(), 1);
|
||||
}
|
||||
|
||||
// ── Replace with new return type ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn replace_normalized_matches_basic() {
|
||||
let text = "say \u{201C}hello\u{201D} world";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SearchReplace (Edit) tool — new architecture (`Tool` trait).
|
||||
//! SearchReplace (Edit) tool.
|
||||
//!
|
||||
//! Replaces an exact string in a file, with support for:
|
||||
//! - New file creation (when `old_string` is empty)
|
||||
@@ -33,7 +33,6 @@ use helpers::{
|
||||
replace_normalized_matches, replace_using_positions,
|
||||
};
|
||||
pub(crate) const CONTEXT_LINES: usize = 3;
|
||||
/// Internal version discriminant for search_replace.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SearchReplaceVersion {
|
||||
Current,
|
||||
@@ -61,7 +60,6 @@ pub(crate) const DESCRIPTION_FULL: &str = r#"Replace an exact string in a file.
|
||||
- Read the file with `${{ tools.by_kind.read }}` before editing it.
|
||||
- `${{ tools.by_kind.read }}` prefixes each line with "LINE_NUMBER→". That prefix is not part of the file: match only what comes after the →, with its exact indentation.
|
||||
- `${{ params.edit.old_string }}` must match exactly one place in the file. If it appears more than once, add surrounding lines to make it unique, or set `${{ params.edit.replace_all }}` to change every occurrence (handy for renaming an identifier)."#;
|
||||
/// Input for the search_replace tool.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SearchReplaceInput {
|
||||
#[schemars(
|
||||
@@ -87,8 +85,6 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
/// Configuration for the search_replace tool, stored as `Params<SearchReplaceParams>` in Resources.
|
||||
///
|
||||
/// Replaces the old `SearchReplaceOptions` that was stored via `tool_options_as()`.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SearchReplaceParams {
|
||||
@@ -96,8 +92,9 @@ pub struct SearchReplaceParams {
|
||||
/// `deny_unknown_fields`. Still gates the config-time Read-tool requirement (`requires_expr`).
|
||||
#[serde(default)]
|
||||
pub skip_read_before_edit: bool,
|
||||
/// Empty old string DOES not override the file unless its empty, by default we allow
|
||||
/// empty old string to override the file content completely``
|
||||
/// When true, an empty `old_string` may only create a new file or overwrite an
|
||||
/// empty one, never clobber an existing non-empty file. Default false: an empty
|
||||
/// `old_string` overwrites the file completely.
|
||||
#[serde(default)]
|
||||
pub empty_old_string_does_not_override: bool,
|
||||
/// When true, enable normalized-fallback matching for Unicode confusable
|
||||
@@ -117,9 +114,6 @@ pub struct SearchReplaceParams {
|
||||
pub include_user_edit_hint: bool,
|
||||
}
|
||||
register_resource!("kigi", "SearchReplace", SearchReplaceParams);
|
||||
/// SearchReplace tool — new architecture.
|
||||
///
|
||||
/// Replaces an exact string in a file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchReplaceTool;
|
||||
/// Core search-replace logic shared by `SearchReplaceTool` and `SearchReplaceConciseTool`.
|
||||
@@ -250,10 +244,6 @@ pub(crate) async fn run_search_replace(
|
||||
/// Maximum length for a single path component (file or directory name).
|
||||
/// POSIX `NAME_MAX` is 255 on both macOS and Linux.
|
||||
const NAME_MAX: usize = 255;
|
||||
/// Validate that no path component exceeds `NAME_MAX`.
|
||||
///
|
||||
/// Returns `Some(SearchReplaceOutput::FilenameTooLong(..))` if any component is
|
||||
/// too long, `None` if the path is valid.
|
||||
fn validate_path_length(file_path: &str) -> Option<SearchReplaceOutput> {
|
||||
for component in std::path::Path::new(file_path).components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
@@ -269,7 +259,6 @@ fn validate_path_length(file_path: &str) -> Option<SearchReplaceOutput> {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Handle new file creation when `old_string` is empty.
|
||||
async fn handle_new_file_creation(
|
||||
input: &SearchReplaceInput,
|
||||
resources: SharedResources,
|
||||
@@ -497,7 +486,6 @@ fn build_confusable_hint(
|
||||
line_summary, read_qualifier, old_string_param, terminal_fallback
|
||||
))
|
||||
}
|
||||
/// Handle replacement in existing file.
|
||||
async fn handle_replacement(
|
||||
input: &SearchReplaceInput,
|
||||
resources: SharedResources,
|
||||
@@ -833,7 +821,6 @@ mod tests {
|
||||
use crate::{computer::local::LocalFs, types::resources::Resources};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
/// Set up Resources with real filesystem for tests.
|
||||
fn test_resources(cwd: &std::path::Path) -> Resources {
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(Cwd(cwd.to_path_buf()));
|
||||
@@ -908,7 +895,6 @@ mod tests {
|
||||
"harness skip_read_before_edit config must validate against SearchReplaceParams",
|
||||
);
|
||||
}
|
||||
/// Consecutive edits to the same file succeed without any prior read.
|
||||
#[tokio::test]
|
||||
async fn consecutive_edits_succeed_without_prior_read() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -1773,7 +1759,6 @@ gamma delta";
|
||||
"should match on longest token, got: {hint}"
|
||||
);
|
||||
}
|
||||
/// Integration: NoMatchesFound message includes the nearest-match hint.
|
||||
#[tokio::test]
|
||||
async fn no_matches_message_includes_hint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2001,7 +1986,6 @@ neutTest_set);
|
||||
hint
|
||||
);
|
||||
}
|
||||
/// Integration: NoMatchesFound message includes confusable hint for smart quotes.
|
||||
#[tokio::test]
|
||||
async fn no_matches_includes_confusable_hint_for_smart_quotes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2034,7 +2018,6 @@ neutTest_set);
|
||||
other => panic!("Expected NoMatchesFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Integration: NoMatchesFound message has NO confusable hint for plain ASCII miss.
|
||||
#[tokio::test]
|
||||
async fn no_matches_no_confusable_hint_for_ascii_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2061,8 +2044,6 @@ neutTest_set);
|
||||
other => panic!("Expected NoMatchesFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Integration: confusables in file but unrelated to the missed old_string
|
||||
/// should NOT produce false guidance.
|
||||
#[tokio::test]
|
||||
async fn no_matches_no_false_confusable_guidance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2098,7 +2079,6 @@ neutTest_set);
|
||||
include_user_edit_hint: false,
|
||||
}
|
||||
}
|
||||
/// Exact match still works and returns unicode_normalized=false.
|
||||
#[tokio::test]
|
||||
async fn fallback_exact_match_still_preferred() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2122,7 +2102,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Smart quotes fallback succeeds with unicode_normalized=true.
|
||||
#[tokio::test]
|
||||
async fn fallback_smart_quotes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2147,7 +2126,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Em-dash fallback succeeds.
|
||||
#[tokio::test]
|
||||
async fn fallback_em_dash() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2168,7 +2146,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// NBSP fallback succeeds.
|
||||
#[tokio::test]
|
||||
async fn fallback_nbsp() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2189,7 +2166,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Ellipsis fallback succeeds.
|
||||
#[tokio::test]
|
||||
async fn fallback_ellipsis() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2210,7 +2186,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Multi-match + replace_all=false returns MultipleMatchesFound.
|
||||
#[tokio::test]
|
||||
async fn fallback_multi_match_without_replace_all() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2237,7 +2212,6 @@ neutTest_set);
|
||||
other => panic!("Expected MultipleMatchesFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Multi-match + replace_all=true replaces all occurrences.
|
||||
#[tokio::test]
|
||||
async fn fallback_multi_match_with_replace_all() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2263,7 +2237,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Fallback disabled by default — smart quotes produce NoMatchesFound.
|
||||
#[tokio::test]
|
||||
async fn fallback_disabled_by_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2286,7 +2259,6 @@ neutTest_set);
|
||||
result
|
||||
);
|
||||
}
|
||||
/// Exact match exists → exact path wins even when confusables present elsewhere.
|
||||
#[tokio::test]
|
||||
async fn fallback_exact_match_wins_over_normalized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2312,7 +2284,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Replacement preserves valid UTF-8 and only mutates matched region.
|
||||
#[tokio::test]
|
||||
async fn fallback_preserves_surrounding_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2374,7 +2345,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Single-line match within a CRLF file works and preserves CRLF.
|
||||
#[tokio::test]
|
||||
async fn crlf_single_line_match_preserves_line_endings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2398,7 +2368,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// LF-only files are unaffected by CRLF normalization logic.
|
||||
#[tokio::test]
|
||||
async fn lf_only_file_unaffected_by_crlf_logic() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -2422,7 +2391,6 @@ neutTest_set);
|
||||
other => panic!("Expected EditsApplied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
/// Replace-all mode works correctly with CRLF files.
|
||||
#[tokio::test]
|
||||
async fn crlf_replace_all() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
-2
@@ -70,8 +70,6 @@ async fn build_render_context(
|
||||
})
|
||||
}
|
||||
|
||||
/// Downgrade structured error variants to generic `InvalidInput` for legacy,
|
||||
/// restoring exact historical 0.4.10 wording.
|
||||
pub(crate) async fn downgrade_structured_errors(
|
||||
output: SearchReplaceOutput,
|
||||
resources: &SharedResources,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Version-specific behavior modules for `search_replace`.
|
||||
//! Version-specific behavior for `search_replace`.
|
||||
//!
|
||||
//! - `legacy_0_4_10`: error downgrade logic that restores exact historical
|
||||
//! 0.4.10 wording by collapsing structured error variants to `InvalidInput`.
|
||||
//! - Current behavior remains in `search_replace/mod.rs` (structured errors,
|
||||
//! gitignore enforcement, confusable diagnostics).
|
||||
//! `legacy_0_4_10` restores exact historical 0.4.10 error wording; current
|
||||
//! behavior lives in `search_replace/mod.rs`.
|
||||
|
||||
pub(crate) mod legacy_0_4_10;
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
const IMAGE_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1 GB
|
||||
const VIDEO_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GB
|
||||
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1 GB
|
||||
const IMAGE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const VIDEO_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
fn budget_for(dir_name: &str) -> u64 {
|
||||
match dir_name {
|
||||
@@ -137,20 +137,17 @@ async fn scan_dir_stats(dir: &Path) -> Result<(u32, u64), std::io::Error> {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
// Remove orphan temp files from interrupted writes
|
||||
if name_str.starts_with(".tmp") {
|
||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track the highest numbered file (any extension)
|
||||
if let Some(stem) = name_str.split_once('.').map(|(s, _)| s)
|
||||
&& let Ok(n) = stem.parse::<u32>()
|
||||
{
|
||||
max = max.max(n);
|
||||
}
|
||||
|
||||
// Sum bytes for budget init
|
||||
if let Ok(meta) = entry.metadata().await {
|
||||
total_bytes += meta.len();
|
||||
}
|
||||
@@ -225,7 +222,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let (max, bytes) = scan_dir_stats(tmp.path()).await.unwrap();
|
||||
assert_eq!(max, 3);
|
||||
assert_eq!(bytes, 4 + 6 + 2); // aaaa + bbbbbb + cc
|
||||
assert_eq!(bytes, 4 + 6 + 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -23,11 +23,9 @@ use super::types::{
|
||||
use crate::register_resource;
|
||||
use kigi_tool_runtime::ToolError;
|
||||
|
||||
/// Abstraction over the mechanism used to spawn, query, and cancel subagents.
|
||||
///
|
||||
/// Injected into `Resources` as [`SubagentBackendResource`] so that
|
||||
/// `TaskTool`, `TaskOutputTool`, and `KillTaskTool` can operate
|
||||
/// identically regardless of the underlying transport.
|
||||
/// Injected into `Resources` as [`SubagentBackendResource`] so `TaskTool`,
|
||||
/// `TaskOutputTool`, and `KillTaskTool` dispatch identically regardless of
|
||||
/// the underlying transport.
|
||||
#[async_trait::async_trait]
|
||||
pub trait SubagentBackend: Send + Sync + 'static {
|
||||
/// Spawn a subagent and await its result.
|
||||
@@ -48,7 +46,6 @@ pub trait SubagentBackend: Send + Sync + 'static {
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Option<SubagentSnapshot>;
|
||||
|
||||
/// Request cancellation of a subagent by ID.
|
||||
async fn cancel(&self, id: &str) -> SubagentCancelOutcome;
|
||||
|
||||
/// Validate a subagent type synchronously before spawning.
|
||||
@@ -554,8 +551,6 @@ mod tests {
|
||||
assert!(snap.is_none());
|
||||
}
|
||||
|
||||
// ── validate_type ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_backend_validate_type_round_trips_outcome() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
||||
@@ -676,8 +671,6 @@ mod tests {
|
||||
holder.abort();
|
||||
}
|
||||
|
||||
// ── describe_subagent_type ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_backend_describe_round_trips_summary() {
|
||||
use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary};
|
||||
|
||||
@@ -30,17 +30,9 @@ use kigi_tool_types::{SubagentCompletedOutput, SubagentIsolationMode, TaskToolIn
|
||||
/// the first subagent is depth 1. Subagents cannot spawn further subagents.
|
||||
pub const MAX_SUBAGENT_DEPTH: u32 = 1;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool implementation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TaskTool;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for TaskTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Task
|
||||
@@ -117,7 +109,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
// 1. Depth check
|
||||
let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = {
|
||||
let res = resources.lock().await;
|
||||
|
||||
@@ -201,7 +192,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
but not both.",
|
||||
));
|
||||
}
|
||||
// Non-existent path alongside worktree — clear it so worktree wins.
|
||||
tracing::debug!(
|
||||
cwd = %cwd.as_deref().unwrap_or(""),
|
||||
"clearing non-existent cwd path because isolation=worktree is set"
|
||||
@@ -211,7 +201,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
cwd
|
||||
};
|
||||
|
||||
// Validate that cwd points to an existing directory (skip when resuming).
|
||||
if let Some(ref cwd_path) = cwd
|
||||
&& resume_from.is_none()
|
||||
{
|
||||
@@ -226,8 +215,8 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Eager validation — catch unknown / disabled / not-allowed
|
||||
// types before the fire-and-forget background spawn.
|
||||
// Eager validation — catch unknown / disabled / not-allowed
|
||||
// types before the fire-and-forget background spawn.
|
||||
match backend
|
||||
.backend()
|
||||
.validate_type(&input.subagent_type, &parent_session_id)
|
||||
@@ -284,7 +273,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build the subagent request
|
||||
let id = input
|
||||
.task_id
|
||||
.clone()
|
||||
@@ -321,7 +309,7 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
result_tx,
|
||||
};
|
||||
|
||||
// 4. Background mode: fire-and-forget via backend.spawn().
|
||||
// Background mode: fire-and-forget via backend.spawn().
|
||||
// Coordinator stores the result for TaskOutputTool polling.
|
||||
// Both transport errors and coordinator rejections are logged so
|
||||
// late failures (worktree creation, etc.) remain visible.
|
||||
@@ -368,10 +356,9 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Blocking mode (default): spawn via backend and await result
|
||||
let result = backend.backend().spawn(request).await?;
|
||||
|
||||
// 5b. The await budget expired and the coordinator auto-backgrounded the
|
||||
// The await budget expired and the coordinator auto-backgrounded the
|
||||
// still-running child — return a task_id to poll, like the background
|
||||
// branch above (the result arrives via auto-wake or a later poll).
|
||||
if result.backgrounded {
|
||||
@@ -397,7 +384,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
|
||||
));
|
||||
}
|
||||
|
||||
// 6. Return result
|
||||
if result.success {
|
||||
let resume_from_hint = result.subagent_id.clone();
|
||||
let persona_hint: Option<String> = None;
|
||||
@@ -498,7 +484,7 @@ mod tests {
|
||||
let (backend, _rx) = make_backend();
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(backend);
|
||||
resources.insert(SubagentDepthCounter(MAX_SUBAGENT_DEPTH)); // at limit
|
||||
resources.insert(SubagentDepthCounter(MAX_SUBAGENT_DEPTH));
|
||||
resources.insert(SessionIdResource("test-session".to_string()));
|
||||
resources.insert(CurrentPromptIdResource("prompt-123".to_string()));
|
||||
|
||||
@@ -531,7 +517,7 @@ mod tests {
|
||||
let (backend, _rx) = make_backend();
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(backend);
|
||||
resources.insert(SubagentDepthCounter(1)); // first-level subagent
|
||||
resources.insert(SubagentDepthCounter(1));
|
||||
resources.insert(SessionIdResource("child-session".to_string()));
|
||||
resources.insert(CurrentPromptIdResource("prompt-456".to_string()));
|
||||
|
||||
@@ -604,7 +590,6 @@ mod tests {
|
||||
let tool = TaskTool;
|
||||
let shared = resources.into_shared();
|
||||
|
||||
// Spawn a task that will handle the request
|
||||
let handle = tokio::spawn(async move {
|
||||
let request = unwrap_spawn(rx.recv().await.unwrap());
|
||||
assert_eq!(request.subagent_type, "explore");
|
||||
@@ -663,7 +648,7 @@ mod tests {
|
||||
let (backend, mut rx) = make_backend();
|
||||
let mut resources = Resources::new();
|
||||
resources.insert(backend);
|
||||
resources.insert(SubagentDepthCounter(0)); // top-level session
|
||||
resources.insert(SubagentDepthCounter(0));
|
||||
resources.insert(SessionIdResource("parent-session".to_string()));
|
||||
resources.insert(CurrentPromptIdResource("prompt-123".to_string()));
|
||||
|
||||
@@ -719,7 +704,6 @@ mod tests {
|
||||
let tool = TaskTool;
|
||||
let shared = resources.into_shared();
|
||||
|
||||
// Spawn a task that drops the result_tx without sending
|
||||
let handle = tokio::spawn(async move {
|
||||
let request = unwrap_spawn(rx.recv().await.unwrap());
|
||||
drop(request.result_tx);
|
||||
@@ -771,7 +755,7 @@ mod tests {
|
||||
let result = kigi_tool_runtime::Tool::run(
|
||||
&TaskTool,
|
||||
test_ctx(resources.into_shared()),
|
||||
task_input("general-purpose", false), // blocking mode
|
||||
task_input("general-purpose", false),
|
||||
)
|
||||
.await
|
||||
.expect("auto-backgrounded blocking spawn returns Ok");
|
||||
@@ -1135,8 +1119,6 @@ mod tests {
|
||||
assert!(capture_rx.try_recv().is_err(), "must fire exactly once");
|
||||
}
|
||||
|
||||
// ── Runtime overrides serde tests ─────────────────
|
||||
|
||||
#[test]
|
||||
fn runtime_overrides_parse() {
|
||||
let input: TaskToolInput = serde_json::from_str(
|
||||
@@ -1274,8 +1256,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Isolation mode tests --
|
||||
|
||||
#[test]
|
||||
fn isolation_defaults_to_none() {
|
||||
let input: TaskToolInput =
|
||||
@@ -1337,8 +1317,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Capability mode enforcement tests --
|
||||
|
||||
fn tc(id: &str, kind: crate::types::tool::ToolKind) -> crate::registry::types::ToolConfig {
|
||||
let mut c = crate::registry::types::ToolConfig::from_id(id);
|
||||
c.kind = Some(kind);
|
||||
@@ -1444,8 +1422,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── resume_from tests ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn task_tool_input_has_no_fork_context_field() {
|
||||
let _: TaskToolInput =
|
||||
@@ -1682,8 +1658,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── cwd tests ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cwd_defaults_to_none() {
|
||||
let input: TaskToolInput =
|
||||
@@ -2270,8 +2244,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── model override tests ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_threads_to_runtime_overrides() {
|
||||
let (backend, mut rx) = make_backend();
|
||||
|
||||
@@ -24,8 +24,6 @@ use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::register_resource;
|
||||
|
||||
// Request / Response
|
||||
|
||||
/// Request emitted by TaskTool, received by MvpAgent coordinator.
|
||||
#[derive(Educe)]
|
||||
#[educe(Debug)]
|
||||
@@ -41,14 +39,13 @@ pub struct SubagentRequest {
|
||||
/// Used to cancel only the subagents spawned by the currently-cancelled turn,
|
||||
/// without affecting background subagents from earlier turns.
|
||||
pub parent_prompt_id: Option<String>,
|
||||
/// Resume from a previously completed subagent's conversation.
|
||||
/// Resume from a completed subagent's conversation.
|
||||
/// Inherits raw transcript, tool state, and model. System prompt is
|
||||
/// freshly rendered.
|
||||
pub resume_from: Option<String>,
|
||||
/// Explicit working directory for the child session.
|
||||
/// Validated at spawn time in `handle_subagent_request()`.
|
||||
pub cwd: Option<String>,
|
||||
/// Runtime overrides for the child agent.
|
||||
pub runtime_overrides: SubagentRuntimeOverrides,
|
||||
/// Whether this subagent was launched with `run_in_background: true`.
|
||||
///
|
||||
@@ -63,15 +60,10 @@ pub struct SubagentRequest {
|
||||
/// Harness-only: seed child with normalized parent conversation, then append
|
||||
/// `prompt`. Not on TaskToolInput. Successful `resume_from` takes precedence.
|
||||
pub fork_context: bool,
|
||||
/// Oneshot channel for the coordinator to send back the result.
|
||||
#[educe(Debug(ignore))]
|
||||
pub result_tx: oneshot::Sender<SubagentResult>,
|
||||
}
|
||||
|
||||
/// Per-spawn dynamic runtime overrides for a subagent.
|
||||
///
|
||||
/// Optional values inherit from the parent or role default. Explicit values take
|
||||
/// precedence over role defaults.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ModelOverrideProvenance {
|
||||
/// Internal harness, role, persona, or config resolution.
|
||||
@@ -81,9 +73,12 @@ pub enum ModelOverrideProvenance {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// Per-spawn dynamic runtime overrides for a subagent.
|
||||
///
|
||||
/// Optional values inherit from the parent or role default. Explicit values take
|
||||
/// precedence over role defaults.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SubagentRuntimeOverrides {
|
||||
/// Override the model (e.g. "test-model").
|
||||
pub model: Option<String>,
|
||||
/// Whether `model` came from a model-facing Task call or internal harness logic.
|
||||
pub model_override_provenance: ModelOverrideProvenance,
|
||||
@@ -107,7 +102,6 @@ pub struct SubagentRuntimeOverrides {
|
||||
pub harness_agent_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Re-export of [`kigi_tool_types::is_not_sentinel`] for existing call sites.
|
||||
pub use kigi_tool_types::is_not_sentinel;
|
||||
|
||||
/// Sanitize a model-emitted `cwd` argument for the `task` tool.
|
||||
@@ -147,7 +141,6 @@ pub trait SubagentCapabilityModeExt {
|
||||
/// `ToolConfig::from_id()`) are preserved unconditionally.
|
||||
fn filter_tool_config(self, config: &mut crate::registry::types::ToolServerConfig);
|
||||
|
||||
/// Return the set of `ToolKind`s allowed under this capability mode.
|
||||
fn allowed_tool_kinds(self) -> &'static [crate::types::tool::ToolKind];
|
||||
}
|
||||
|
||||
@@ -199,7 +192,6 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
|
||||
prune_orphaned_background_task_tools(config);
|
||||
}
|
||||
|
||||
/// Return the set of `ToolKind`s allowed under this capability mode.
|
||||
fn allowed_tool_kinds(self) -> &'static [crate::types::tool::ToolKind] {
|
||||
use crate::types::tool::ToolKind;
|
||||
match self {
|
||||
@@ -304,7 +296,6 @@ pub struct SubagentResult {
|
||||
/// bump rather than a full copy. Subagent outputs can be arbitrarily
|
||||
/// large (entire transcript), so this matters at scale.
|
||||
pub output: Arc<str>,
|
||||
/// Error message if the subagent failed.
|
||||
pub error: Option<String>,
|
||||
/// True if the subagent was cancelled (by user or model).
|
||||
/// Distinct from failure — cancellation is intentional.
|
||||
@@ -358,19 +349,15 @@ impl SubagentResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Query protocol
|
||||
|
||||
/// Query sent by TaskOutputTool, received by MvpAgent coordinator.
|
||||
#[derive(Educe)]
|
||||
#[educe(Debug)]
|
||||
pub struct SubagentQueryRequest {
|
||||
/// The subagent ID to look up.
|
||||
pub subagent_id: String,
|
||||
/// If true, coordinator waits for completion (up to timeout) before responding.
|
||||
pub block: bool,
|
||||
/// Max wait time in ms when blocking. Default 30s.
|
||||
pub timeout_ms: Option<u64>,
|
||||
/// Oneshot for the coordinator to send back the snapshot.
|
||||
#[educe(Debug(ignore))]
|
||||
pub respond_to: oneshot::Sender<Option<SubagentSnapshot>>,
|
||||
}
|
||||
@@ -387,7 +374,6 @@ pub struct SubagentSnapshot {
|
||||
pub started_at_epoch_ms: u64,
|
||||
/// Elapsed wall-clock time in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
/// Persona used by this subagent, if any.
|
||||
pub persona: Option<String>,
|
||||
}
|
||||
|
||||
@@ -439,8 +425,6 @@ impl SubagentSnapshotStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel protocol
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentCancelTarget {
|
||||
SubagentId(String),
|
||||
@@ -539,8 +523,6 @@ pub struct SubagentMarkUsageNotAppliedRequest {
|
||||
pub respond_to: oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
// Validate-type protocol
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum SubagentValidateTypeOutcome {
|
||||
@@ -566,8 +548,6 @@ pub struct SubagentValidateTypeRequest {
|
||||
pub respond_to: oneshot::Sender<SubagentValidateTypeOutcome>,
|
||||
}
|
||||
|
||||
// Describe-type protocol
|
||||
|
||||
/// Outcome of a `describe_subagent_type` round-trip.
|
||||
///
|
||||
/// Mirrors [`SubagentValidateTypeOutcome`] but, on success, additionally
|
||||
@@ -655,8 +635,6 @@ pub enum SubagentEvent {
|
||||
DescribeType(SubagentDescribeRequest),
|
||||
}
|
||||
|
||||
// Resource types
|
||||
|
||||
/// Unified sender for all subagent coordinator events.
|
||||
///
|
||||
/// Cloned into each session's `ToolContext` / `ToolBridge Resources` so
|
||||
@@ -669,8 +647,6 @@ pub struct SubagentEventSender(#[educe(Debug(ignore))] pub mpsc::UnboundedSender
|
||||
|
||||
register_resource!("kigi", "SubagentEventSender", SubagentEventSender);
|
||||
|
||||
// Mid-turn monitor event buffer
|
||||
|
||||
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonitorEventNotification {
|
||||
@@ -714,8 +690,6 @@ pub fn drain_owned(
|
||||
buffer.drain_matching(|e| e.owned_by_session(my_owner))
|
||||
}
|
||||
|
||||
// Active subagent listing (compaction)
|
||||
|
||||
/// Lightweight summary of a running subagent.
|
||||
///
|
||||
/// This is the single shared definition of this type. The coordinator in
|
||||
@@ -893,7 +867,6 @@ mod tests {
|
||||
use super::SubagentCapabilityModeExt;
|
||||
use super::is_valid_resume_id;
|
||||
|
||||
/// Create a `ToolConfig` with the given id and kind set.
|
||||
fn tc(id: &str, kind: ToolKind) -> ToolConfig {
|
||||
let mut c = ToolConfig::from_id(id);
|
||||
c.kind = Some(kind);
|
||||
|
||||
@@ -180,7 +180,6 @@ impl TaskOutputTool {
|
||||
return Ok(format_subagent_snapshot(&snapshot));
|
||||
}
|
||||
|
||||
// Neither found
|
||||
{
|
||||
let msg = if is_legacy {
|
||||
render_legacy_task_output_not_found(task_id)
|
||||
@@ -348,11 +347,6 @@ pub(crate) async fn resolve_tasks(
|
||||
pending_subagent_ids,
|
||||
}
|
||||
}
|
||||
//
|
||||
// Uses `TerminalBackend::wait_for_completion` for bash tasks (event-driven via
|
||||
// the underlying `Notify`) and `SubagentQueryRequest { block: true }` for
|
||||
// subagents (blocks in the coordinator until the child session finishes).
|
||||
// No 200ms polling loop — wakeups happen on actual state transitions.
|
||||
|
||||
/// Aborts all wrapped helper-wait tasks when dropped.
|
||||
///
|
||||
@@ -482,15 +476,6 @@ pub(crate) async fn wait_all_event_driven(
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Historical fixture captured from the 0.4.10 implementation.
|
||||
//
|
||||
// In 0.4.10, get_task_output returned:
|
||||
// Err(ToolError::ProcessManagerError(format!("Task {} not found", input.task_id)))
|
||||
//
|
||||
// The meaningful customer-facing message content is the inner string.
|
||||
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
|
||||
|
||||
/// Exact historical not-found message for `get_task_output` in legacy-0.4.10.
|
||||
fn render_legacy_task_output_not_found(task_id: &str) -> String {
|
||||
format!("Task {} not found", task_id)
|
||||
@@ -939,16 +924,12 @@ mod tests {
|
||||
// unbounded blocking wait wedged the turn for hours).
|
||||
#[test]
|
||||
fn capped_wait_timeout_clamps_and_defaults() {
|
||||
// Omitted -> default 30s.
|
||||
assert_eq!(capped_wait_timeout(None), DEFAULT_WAIT_TIMEOUT);
|
||||
// Small value -> unchanged.
|
||||
assert_eq!(
|
||||
capped_wait_timeout(Some(5_000)),
|
||||
Duration::from_millis(5_000)
|
||||
);
|
||||
// Huge value (10h) -> clamped to the cap.
|
||||
assert_eq!(capped_wait_timeout(Some(36_000_000)), MAX_WAIT_BLOCK);
|
||||
// Exactly at the cap (10m) -> unchanged.
|
||||
assert_eq!(capped_wait_timeout(Some(600_000)), MAX_WAIT_BLOCK);
|
||||
}
|
||||
|
||||
@@ -959,9 +940,6 @@ mod tests {
|
||||
kigi_tool_runtime::Tool::id(&tool).as_str(),
|
||||
"get_task_output"
|
||||
);
|
||||
// The static fallback is the shared builder's default kigi
|
||||
// rendering (monitor + task + bash + read present): concrete names, no
|
||||
// leftover template markers.
|
||||
let desc = ToolMetadata::description_template(&tool);
|
||||
assert!(desc.contains("Get output and status from a background task"));
|
||||
// Must name "monitor" so the model connects polling a monitor to this tool.
|
||||
@@ -1165,7 +1143,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(); // Should be Ok, not Err
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
TaskOutputOutput::TaskNotFound(msg) => {
|
||||
@@ -1205,7 +1183,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn get_task(&self, _task_id: &str) -> Option<TaskSnapshot> {
|
||||
None // requested task not found
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_completion(
|
||||
@@ -1242,7 +1220,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(); // Should be Ok, not Err
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
TaskOutputOutput::TaskNotFound(msg) => {
|
||||
@@ -1257,7 +1235,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_task_not_found_block_mode_lists_known_tasks() {
|
||||
// Verify that blocking mode also provides helpful errors.
|
||||
struct MockTerminalBlockNotFound;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1289,7 +1266,7 @@ mod tests {
|
||||
_task_id: &str,
|
||||
_timeout: Option<Duration>,
|
||||
) -> Option<TaskSnapshot> {
|
||||
None // task not found even when blocking
|
||||
None
|
||||
}
|
||||
|
||||
async fn list_tasks(&self) -> Vec<TaskSnapshot> {
|
||||
@@ -1354,10 +1331,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn uses_tool_name_mapping_for_truncation_hint() {
|
||||
let mut snapshot = make_snapshot("task-4", true, Some(0));
|
||||
snapshot.output = "x".repeat(500_000); // large output triggers truncation
|
||||
// large output exceeds the default cap and triggers truncation
|
||||
snapshot.output = "x".repeat(500_000);
|
||||
|
||||
let mut resources = resources_with_terminal(Some(snapshot));
|
||||
// Set a custom model-facing name for the Read tool
|
||||
resources.insert(TemplateRenderer::new(
|
||||
[(ToolKind::Read, "Read".to_string())].into(),
|
||||
Default::default(),
|
||||
@@ -1430,7 +1407,6 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(with_timeout.waits());
|
||||
|
||||
// Legacy block is ignored; wait is driven only by timeout_ms.
|
||||
let legacy_block_false: TaskOutputToolInput = serde_json::from_value(serde_json::json!({
|
||||
"task_ids": ["t"],
|
||||
"block": false,
|
||||
@@ -1470,11 +1446,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn respects_truncation_config() {
|
||||
let mut snapshot = make_snapshot("task-6", true, Some(0));
|
||||
snapshot.output = "x".repeat(10_000); // 10KB
|
||||
snapshot.output = "x".repeat(10_000);
|
||||
|
||||
let mut resources = resources_with_terminal(Some(snapshot));
|
||||
|
||||
// Set a custom truncation config with 5KB limit
|
||||
let mut trunc = crate::types::context::TruncationConfig::default();
|
||||
trunc
|
||||
.per_tool_max_output_bytes
|
||||
@@ -1505,16 +1480,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legacy message parity fixture tests ────────────────────────
|
||||
//
|
||||
// These tests verify exact historical wording for legacy-0.4.10.
|
||||
// Fixture source: the historical 0.4.10 task_output implementation.
|
||||
//
|
||||
// Historical 0.4.10 message (inner string from ToolError::ProcessManagerError):
|
||||
// "Task {task_id} not found"
|
||||
//
|
||||
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_get_task_not_found_exact_historical_message() {
|
||||
let resources = resources_with_terminal(None);
|
||||
@@ -1547,8 +1512,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_get_task_not_found_includes_discoverability() {
|
||||
// Current (non-legacy) path must still include known task IDs
|
||||
// or "No background tasks" text for discoverability.
|
||||
let resources = resources_with_terminal(None);
|
||||
let tool = TaskOutputTool;
|
||||
|
||||
@@ -1574,8 +1537,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subagent running snapshot formatting ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_initializing_subagent_reports_status() {
|
||||
let snap = SubagentSnapshot {
|
||||
@@ -1644,7 +1605,6 @@ mod tests {
|
||||
assert_eq!(r.status, "running");
|
||||
assert!(r.exit_code.is_none());
|
||||
assert!(r.ended.is_none());
|
||||
// Progress line
|
||||
assert!(
|
||||
r.output.contains("turn 3"),
|
||||
"should contain turn count: {}",
|
||||
@@ -1665,13 +1625,11 @@ mod tests {
|
||||
"should contain context pct: {}",
|
||||
r.output
|
||||
);
|
||||
// Tools used
|
||||
assert!(
|
||||
r.output.contains("bash, read_file, grep"),
|
||||
"should contain tools list: {}",
|
||||
r.output
|
||||
);
|
||||
// Errors
|
||||
assert!(
|
||||
r.output.contains("Errors: 0"),
|
||||
"should contain error count: {}",
|
||||
@@ -1790,8 +1748,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subagent backend query fallback tests ────────────────────────────
|
||||
|
||||
/// Build resources with a terminal that returns None (task not found)
|
||||
/// and a SubagentBackendResource backed by the unified event channel.
|
||||
fn resources_with_backend_query() -> (
|
||||
@@ -1814,7 +1770,6 @@ mod tests {
|
||||
(resources, rx)
|
||||
}
|
||||
|
||||
/// Extract a `SubagentQueryRequest` from a `SubagentEvent`, panicking on wrong variant.
|
||||
fn unwrap_query(
|
||||
event: crate::implementations::kigi::task::types::SubagentEvent,
|
||||
) -> crate::implementations::kigi::task::types::SubagentQueryRequest {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! TodoWrite — new-architecture implementation.
|
||||
//!
|
||||
//! Reuses the core logic (`validate_no_duplicate_ids`, `apply_replace`,
|
||||
//! `apply_merge`, `summarize_todo_state`) from the old `implementations::todo`
|
||||
//! module. State is stored as `State<TodoState>` in Resources instead of
|
||||
//! `ToolState.todo_state`.
|
||||
//! `TodoWrite` tool: the model-maintained task list, persisted as
|
||||
//! `State<TodoState>` in Resources.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -72,7 +68,6 @@ pub(crate) fn apply_replace(
|
||||
pub(crate) fn apply_merge(state: &mut TodoState, updates: &[TodoUpdate]) -> Result<(), TodoError> {
|
||||
for u in updates {
|
||||
if state.update(&u.id, u.content.as_deref(), u.status) {
|
||||
// Existing item – partial update succeeded, content was optional.
|
||||
continue;
|
||||
}
|
||||
let content = if u.has_no_content() {
|
||||
@@ -252,7 +247,7 @@ pub struct TodoWriteInput {
|
||||
pub todos: Vec<TodoUpdate>,
|
||||
}
|
||||
|
||||
/// New-architecture `TodoWrite` tool.
|
||||
/// `TodoWrite` tool.
|
||||
///
|
||||
/// State: `State<TodoState>` — persisted across calls via Resources serde.
|
||||
/// Params: `()` — no per-tool configuration.
|
||||
@@ -368,8 +363,6 @@ mod tests {
|
||||
use crate::types::resources::Resources;
|
||||
use crate::types::tool_metadata::test_ctx;
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
fn make_update(id: &str, content: Option<&str>, status: Option<TodoStatus>) -> TodoUpdate {
|
||||
TodoUpdate {
|
||||
id: id.to_owned(),
|
||||
@@ -386,8 +379,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tests --
|
||||
|
||||
#[test]
|
||||
fn name_and_description() {
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
@@ -419,7 +410,6 @@ mod tests {
|
||||
assert!(output.summary_for_prompt.contains("Task A"));
|
||||
assert!(output.summary_for_prompt.contains("Task B"));
|
||||
|
||||
// State persists in Resources
|
||||
let res = shared.lock().await;
|
||||
let state = res.get::<State<TodoState>>().unwrap();
|
||||
assert_eq!(state.0.todo_items().count(), 2);
|
||||
@@ -431,7 +421,6 @@ mod tests {
|
||||
let resources = Resources::new();
|
||||
let shared = resources.into_shared();
|
||||
|
||||
// Seed initial state
|
||||
let input1 = TodoWriteInput {
|
||||
merge: false,
|
||||
todos: vec![make_update(
|
||||
@@ -444,7 +433,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Replace with new
|
||||
let input2 = TodoWriteInput {
|
||||
merge: false,
|
||||
todos: vec![make_update(
|
||||
@@ -469,7 +457,6 @@ mod tests {
|
||||
let resources = Resources::new();
|
||||
let shared = resources.into_shared();
|
||||
|
||||
// Create initial items
|
||||
let input1 = TodoWriteInput {
|
||||
merge: false,
|
||||
todos: vec![
|
||||
@@ -481,7 +468,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Merge: mark item 1 completed (no content), add item 3
|
||||
let input2 = TodoWriteInput {
|
||||
merge: true,
|
||||
todos: vec![
|
||||
@@ -496,7 +482,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(output.todos.len(), 3);
|
||||
|
||||
// Item 1 content preserved, status updated
|
||||
let item1 = output
|
||||
.todos
|
||||
.iter()
|
||||
@@ -510,7 +495,6 @@ mod tests {
|
||||
let tool = TodoWriteTool;
|
||||
let resources = Resources::new();
|
||||
|
||||
// Merge into empty state — should not error
|
||||
let input = TodoWriteInput {
|
||||
merge: true,
|
||||
todos: vec![make_update("explore", None, Some(TodoStatus::Completed))],
|
||||
@@ -521,7 +505,6 @@ mod tests {
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(output.todos.len(), 1);
|
||||
// Id used as fallback content
|
||||
assert_eq!(output.todos[0].content, "explore");
|
||||
assert_eq!(output.todos[0].status, TodoStatus::Completed);
|
||||
}
|
||||
@@ -580,7 +563,6 @@ mod tests {
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// state field should match what's in Resources
|
||||
assert!(!output.state.is_empty());
|
||||
assert_eq!(output.state.todo_items().count(), 1);
|
||||
}
|
||||
@@ -591,7 +573,6 @@ mod tests {
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<TodoState>();
|
||||
|
||||
// Create some state
|
||||
let input = TodoWriteInput {
|
||||
merge: false,
|
||||
todos: vec![
|
||||
@@ -604,7 +585,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Serialize
|
||||
let res = shared.lock().await;
|
||||
let snapshot = res.serialize();
|
||||
let state_map = snapshot.get("state").unwrap();
|
||||
@@ -613,7 +593,6 @@ mod tests {
|
||||
"TodoState should serialize under 'kigi.Todo'"
|
||||
);
|
||||
|
||||
// Deserialize into fresh Resources
|
||||
let mut resources2 = Resources::new();
|
||||
resources2.register_state::<TodoState>();
|
||||
let data: std::collections::HashMap<
|
||||
@@ -622,7 +601,6 @@ mod tests {
|
||||
> = serde_json::from_value(snapshot).unwrap();
|
||||
resources2.load_from(data);
|
||||
|
||||
// Verify state was restored
|
||||
let restored = resources2.get::<State<TodoState>>().unwrap();
|
||||
assert_eq!(restored.0.todo_items().count(), 2);
|
||||
let items: Vec<_> = restored.0.todo_items().collect();
|
||||
@@ -656,8 +634,6 @@ mod tests {
|
||||
.unwrap_or_else(|| panic!("item {id} not found in state"))
|
||||
}
|
||||
|
||||
// ── replace (merge=false) ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn replace_without_content_falls_back_to_id() {
|
||||
let mut state = TodoState::default();
|
||||
@@ -669,7 +645,7 @@ mod tests {
|
||||
apply_replace(&mut state, &updates).unwrap();
|
||||
|
||||
let item = get_item(&state, "build_project");
|
||||
assert_eq!(item.content, "build_project"); // id used as fallback
|
||||
assert_eq!(item.content, "build_project");
|
||||
assert_eq!(item.status, TodoStatus::Pending);
|
||||
}
|
||||
|
||||
@@ -709,13 +685,10 @@ mod tests {
|
||||
)];
|
||||
apply_replace(&mut state, &updates).unwrap();
|
||||
|
||||
// Old item is gone.
|
||||
assert!(!state.todo_items_with_ids().any(|(id, _)| *id == "old"));
|
||||
assert_eq!(get_item(&state, "new").content, "New task");
|
||||
}
|
||||
|
||||
// ── merge (merge=true) ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_existing_item_status_only() {
|
||||
// The core use-case: mark in_progress → completed without sending content.
|
||||
@@ -725,7 +698,7 @@ mod tests {
|
||||
|
||||
let item = get_item(&state, "1");
|
||||
assert_eq!(item.status, TodoStatus::Completed);
|
||||
assert_eq!(item.content, "Build the project"); // unchanged
|
||||
assert_eq!(item.content, "Build the project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -767,7 +740,7 @@ mod tests {
|
||||
apply_merge(&mut state, &updates).unwrap();
|
||||
|
||||
let item = get_item(&state, "explore_codebase");
|
||||
assert_eq!(item.content, "explore_codebase"); // id used as fallback
|
||||
assert_eq!(item.content, "explore_codebase");
|
||||
assert_eq!(item.status, TodoStatus::Completed);
|
||||
}
|
||||
|
||||
@@ -799,24 +772,20 @@ mod tests {
|
||||
fn merge_mixed_existing_and_new() {
|
||||
let mut state = seed_state(&[("exist", "Existing task", TodoStatus::InProgress)]);
|
||||
let updates = vec![
|
||||
// Update existing — content omitted, just flip status.
|
||||
make_update("exist", None, Some(TodoStatus::Completed)),
|
||||
// Brand-new item — content required.
|
||||
make_update("fresh", Some("New task"), Some(TodoStatus::Pending)),
|
||||
];
|
||||
apply_merge(&mut state, &updates).unwrap();
|
||||
|
||||
let existing = get_item(&state, "exist");
|
||||
assert_eq!(existing.status, TodoStatus::Completed);
|
||||
assert_eq!(existing.content, "Existing task"); // preserved
|
||||
assert_eq!(existing.content, "Existing task");
|
||||
|
||||
let fresh = get_item(&state, "fresh");
|
||||
assert_eq!(fresh.content, "New task");
|
||||
assert_eq!(fresh.status, TodoStatus::Pending);
|
||||
}
|
||||
|
||||
// ── duplicate id validation ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_rejected_unit() {
|
||||
let updates = vec![
|
||||
@@ -836,8 +805,6 @@ mod tests {
|
||||
validate_no_duplicate_ids(&updates).unwrap();
|
||||
}
|
||||
|
||||
// ── regression: missing merge=true auto-upgrade ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_merge_flag_auto_upgrades_when_status_only() {
|
||||
// Regression: status-only update without merge=true must not wipe content.
|
||||
@@ -845,7 +812,6 @@ mod tests {
|
||||
let resources = Resources::new();
|
||||
let shared = resources.into_shared();
|
||||
|
||||
// Create todos with content
|
||||
let input1 = TodoWriteInput {
|
||||
merge: false,
|
||||
todos: vec![
|
||||
@@ -858,9 +824,8 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Status-only update without merge=true
|
||||
let input2 = TodoWriteInput {
|
||||
merge: false, // model forgot merge: true
|
||||
merge: false,
|
||||
todos: vec![
|
||||
make_update("1", None, Some(TodoStatus::Completed)),
|
||||
make_update("2", None, Some(TodoStatus::Completed)),
|
||||
@@ -873,7 +838,6 @@ mod tests {
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Content must be preserved, not replaced with id fallback.
|
||||
assert_eq!(output.todos.len(), 3);
|
||||
assert_eq!(output.todos[0].content, "Explore codebase");
|
||||
assert_eq!(output.todos[0].status, TodoStatus::Completed);
|
||||
@@ -883,8 +847,6 @@ mod tests {
|
||||
assert_eq!(output.todos[2].status, TodoStatus::InProgress);
|
||||
}
|
||||
|
||||
// ── regression: merge with null content should never error ────────
|
||||
|
||||
#[test]
|
||||
fn merge_after_replace_status_update_with_null_content() {
|
||||
// Reproduces the exact scenario from the bug report:
|
||||
@@ -892,7 +854,6 @@ mod tests {
|
||||
// 2. Merge updates 2 items with content=null, status changed
|
||||
let mut state = TodoState::default();
|
||||
|
||||
// Step 1: replace (merge=false)
|
||||
let initial = vec![
|
||||
make_update(
|
||||
"explore_codebase",
|
||||
@@ -912,14 +873,12 @@ mod tests {
|
||||
];
|
||||
apply_replace(&mut state, &initial).unwrap();
|
||||
|
||||
// Step 2: merge (merge=true) — content=null, just status changes
|
||||
let updates = vec![
|
||||
make_update("explore_codebase", None, Some(TodoStatus::Completed)),
|
||||
make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)),
|
||||
];
|
||||
apply_merge(&mut state, &updates).unwrap();
|
||||
|
||||
// Statuses flipped, content preserved from step 1.
|
||||
assert_eq!(
|
||||
get_item(&state, "explore_codebase").status,
|
||||
TodoStatus::Completed
|
||||
@@ -936,15 +895,12 @@ mod tests {
|
||||
get_item(&state, "analyze_and_propose").content,
|
||||
"Analyze current SQLite min version"
|
||||
);
|
||||
// Third item unchanged.
|
||||
assert_eq!(
|
||||
get_item(&state, "implementation").status,
|
||||
TodoStatus::Pending
|
||||
);
|
||||
}
|
||||
|
||||
// ── regression: empty-string content must not wipe existing content ──
|
||||
|
||||
#[test]
|
||||
fn merge_existing_item_empty_string_content_preserves_original() {
|
||||
// Model sends content: "" instead of omitting it. Must not wipe.
|
||||
@@ -954,7 +910,7 @@ mod tests {
|
||||
|
||||
let item = get_item(&state, "1");
|
||||
assert_eq!(item.status, TodoStatus::Completed);
|
||||
assert_eq!(item.content, "Build the project"); // unchanged
|
||||
assert_eq!(item.content, "Build the project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,10 +10,6 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
pub use kigi_tools_api::slash_commands::UPDATE_GOAL_TOOL_NAME;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct UpdateGoalInput {
|
||||
#[serde(
|
||||
@@ -38,9 +34,7 @@ pub struct UpdateGoalInput {
|
||||
pub blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel types — inserted into Resources, read by SessionActor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outcome of an `update_goal` call as delivered by the session actor.
|
||||
#[derive(Debug)]
|
||||
@@ -120,7 +114,7 @@ pub enum RejectReason {
|
||||
/// `harness_no_ack` "dropped the response channel" error).
|
||||
HarnessDisabled,
|
||||
/// Reserved for strict-mode eviction surfacing; not currently
|
||||
/// constructed (the new design acks evicted entries as
|
||||
/// constructed (the design acks evicted entries as
|
||||
/// `DeferredToTurnEnd` at their own defer time).
|
||||
PendingQueueEvicted,
|
||||
/// The goal auto-paused mid-drain (cap, stall/no_progress, or
|
||||
@@ -178,10 +172,6 @@ impl std::fmt::Debug for GoalUpdateHandle {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct UpdateGoalOutput {
|
||||
pub success: bool,
|
||||
@@ -190,10 +180,6 @@ pub struct UpdateGoalOutput {
|
||||
|
||||
impl kigi_tool_runtime::ToolOutput for UpdateGoalOutput {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct UpdateGoalTool;
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ pub(crate) struct FetchCache {
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
/// Simple cache that holds N completed fetch requests on a TTL.
|
||||
impl FetchCache {
|
||||
pub(crate) fn new(ttl: Duration, max_entries: usize) -> Self {
|
||||
Self {
|
||||
@@ -43,7 +42,6 @@ impl FetchCache {
|
||||
return;
|
||||
}
|
||||
if self.entries.len() >= self.max_entries {
|
||||
// Evict oldest entry.
|
||||
let oldest_key = self
|
||||
.entries
|
||||
.iter()
|
||||
|
||||
@@ -19,7 +19,6 @@ use scraper::{Html, Selector};
|
||||
|
||||
const DEFAULT_DOWNLOAD_DIR: &str = "downloads";
|
||||
|
||||
/// Shared HTTP client and cache for web fetching.
|
||||
#[derive(Clone)]
|
||||
pub struct WebFetchClient {
|
||||
http: HttpClient,
|
||||
@@ -57,7 +56,6 @@ impl WebFetchClient {
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
// Reqwest client can fail to build.
|
||||
http: HttpClient::new(params)?,
|
||||
cache: Arc::new(parking_lot::RwLock::new(FetchCache::new(
|
||||
params.cache_ttl_secs(),
|
||||
@@ -149,7 +147,6 @@ impl WebFetchClient {
|
||||
|
||||
let url_str = url.to_string();
|
||||
|
||||
// Check cache.
|
||||
{
|
||||
let cache = self.cache.read();
|
||||
if let Some(cached) = cache.get(&url_str) {
|
||||
@@ -185,10 +182,8 @@ impl WebFetchClient {
|
||||
}
|
||||
}
|
||||
|
||||
// SSRF check.
|
||||
ssrf::check_ssrf(&url).await?;
|
||||
|
||||
// Make request and build output.
|
||||
let http = self.http.get_or_rebuild()?;
|
||||
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
|
||||
Ok(result) => result,
|
||||
@@ -233,7 +228,6 @@ impl WebFetchClient {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
// Image: validate magic bytes, save to disk.
|
||||
if is_image(&content_type) {
|
||||
if !validate_media_magic_bytes(&content_type, &body) {
|
||||
return Err(WebFetchError::ContentTypeMismatch {
|
||||
@@ -255,7 +249,6 @@ impl WebFetchClient {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
// Video: validate magic bytes, save to disk.
|
||||
if is_video(&content_type) {
|
||||
if !validate_media_magic_bytes(&content_type, &body) {
|
||||
return Err(WebFetchError::ContentTypeMismatch {
|
||||
@@ -310,7 +303,6 @@ impl WebFetchClient {
|
||||
output_location: None,
|
||||
});
|
||||
|
||||
// Insert into cache.
|
||||
{
|
||||
let mut cache = self.cache.write();
|
||||
cache.insert_text(url_str, output.clone(), was_truncated);
|
||||
@@ -363,10 +355,6 @@ impl WebFetchClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// URL Validation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validates URL scheme, length, credentials, and hostname labels.
|
||||
fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
||||
if raw.len() > MAX_URL_LENGTH {
|
||||
@@ -375,7 +363,7 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
||||
});
|
||||
}
|
||||
|
||||
let parsed = Url::parse(raw)?; // uses #[from] url::ParseError
|
||||
let parsed = Url::parse(raw)?;
|
||||
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
@@ -401,17 +389,12 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Upgrade `http://` to `https://`.
|
||||
fn upgrade_to_https(url: &mut Url) {
|
||||
if url.scheme() == "http" {
|
||||
let _ = url.set_scheme("https");
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// HTTP Fetching
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum FetchResult {
|
||||
Content {
|
||||
body: Vec<u8>,
|
||||
@@ -434,7 +417,6 @@ async fn fetch_url(
|
||||
let mut current_url = url.clone();
|
||||
let mut hops = 0;
|
||||
|
||||
// Loop to follow redirects under the same host.
|
||||
loop {
|
||||
let resp = client
|
||||
.get(current_url.as_str())
|
||||
@@ -455,7 +437,6 @@ async fn fetch_url(
|
||||
return Err(WebFetchError::TooManyRedirects { max: MAX_REDIRECTS });
|
||||
}
|
||||
|
||||
// Follow same host; break on cross-host.
|
||||
if let Some(location) = resp.headers().get("location") {
|
||||
let location_str = location.to_str().unwrap_or("");
|
||||
let next_url = current_url
|
||||
@@ -507,10 +488,6 @@ fn is_same_host(a: &Url, b: &Url) -> bool {
|
||||
strip_www(host_a) == strip_www(host_b)
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Content Processing
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
|
||||
session_folder.ok_or_else(|| {
|
||||
WebFetchError::IoError(std::io::Error::new(
|
||||
@@ -567,11 +544,10 @@ fn validate_media_magic_bytes(content_type: &str, body: &[u8]) -> bool {
|
||||
"image/webp" => body.len() >= 12 && &body[..4] == b"RIFF" && &body[8..12] == b"WEBP",
|
||||
"video/mp4" => body.len() >= 8 && &body[4..8] == b"ftyp",
|
||||
"video/webm" => body.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]),
|
||||
_ => true, // unknown subtypes: allow (fail-open for niche formats)
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a media Content-Type to the correct file extension.
|
||||
fn media_extension(content_type: &str) -> &'static str {
|
||||
let mime = content_type
|
||||
.split(';')
|
||||
@@ -634,8 +610,6 @@ fn is_binary_content_type(content_type: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Save fetched PDF bytes to the session download directory and return a
|
||||
/// `WebFetchOutput` pointing the model at the saved file.
|
||||
async fn save_pdf(
|
||||
writer: &SessionFileWriter,
|
||||
session_folder: &Path,
|
||||
@@ -677,8 +651,6 @@ async fn save_pdf(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Save fetched image bytes to the session images directory and return a
|
||||
/// `WebFetchOutput` pointing the model at the saved file.
|
||||
async fn save_image(
|
||||
writer: &SessionFileWriter,
|
||||
session_folder: &Path,
|
||||
@@ -722,8 +694,6 @@ async fn save_image(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Save fetched video bytes to the session videos directory and return a
|
||||
/// `WebFetchOutput` pointing the model at the saved file.
|
||||
async fn save_video(
|
||||
writer: &SessionFileWriter,
|
||||
session_folder: &Path,
|
||||
@@ -869,7 +839,6 @@ fn strip_base64_data_uris(content: String) -> String {
|
||||
let mime = if mime.is_empty() { "unknown" } else { mime };
|
||||
|
||||
if parts.any(|p| p.eq_ignore_ascii_case("base64")) {
|
||||
// Consume valid base64 characters after the comma.
|
||||
let payload_start = comma + 1;
|
||||
let payload_len = s[payload_start..]
|
||||
.bytes()
|
||||
@@ -900,10 +869,6 @@ fn strip_base64_data_uris(content: String) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1050,8 +1015,6 @@ mod tests {
|
||||
assert_eq!(tokio::fs::read_to_string(artifact).await.unwrap(), expected);
|
||||
}
|
||||
|
||||
// ── URL validation ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_valid() {
|
||||
assert!(validate_url("https://docs.rs/reqwest/latest").is_ok());
|
||||
@@ -1105,8 +1068,6 @@ mod tests {
|
||||
assert_eq!(url.scheme(), "https");
|
||||
}
|
||||
|
||||
// ── Same-host redirect check ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn same_host_exact_match() {
|
||||
let a = Url::parse("https://example.com/a").unwrap();
|
||||
@@ -1129,8 +1090,6 @@ mod tests {
|
||||
assert!(!is_same_host(&a, &d));
|
||||
}
|
||||
|
||||
// ── Content type detection ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_html_detects_html_types() {
|
||||
assert!(is_html("text/html"));
|
||||
@@ -1146,8 +1105,6 @@ mod tests {
|
||||
assert!(!is_html("application/pdf"));
|
||||
}
|
||||
|
||||
// ── PDF content type detection ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_pdf_detects_pdf_types() {
|
||||
assert!(is_pdf("application/pdf"));
|
||||
@@ -1162,8 +1119,6 @@ mod tests {
|
||||
assert!(!is_pdf("application/octet-stream"));
|
||||
}
|
||||
|
||||
// ── Binary content type detection ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn binary_detects_images_and_media() {
|
||||
assert!(is_binary_content_type("image/png"));
|
||||
@@ -1200,8 +1155,6 @@ mod tests {
|
||||
assert!(!is_binary_content_type("application/graphql"));
|
||||
}
|
||||
|
||||
// ── HTML to markdown conversion ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn html_to_markdown_basic() {
|
||||
let md = html_to_markdown(&test_converter(), "<h1>Hello</h1><p>World</p>");
|
||||
@@ -1340,8 +1293,6 @@ mod tests {
|
||||
assert!(md.contains("Beta"));
|
||||
}
|
||||
|
||||
// ── Base64 data URI stripping ─────────────────────────────────────
|
||||
|
||||
/// Golden test: verify exact output format for the most common case.
|
||||
#[test]
|
||||
fn strip_base64_output_format() {
|
||||
@@ -1400,8 +1351,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regex equivalence ──────────────────────────────────────────────
|
||||
|
||||
/// Reference implementation: the original regex-based stripper.
|
||||
fn strip_base64_data_uris_regex(content: &str) -> String {
|
||||
let re = regex::Regex::new(r"data:([^;,\s]{1,80});base64,[A-Za-z0-9+/=]+")
|
||||
@@ -1463,8 +1412,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proxy configs ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn proxy_endpoint_round_trips_through_config() {
|
||||
let json = r#"{"proxy_endpoint": "https://proxy.corp.example.com", "allowed_domains": ["example.com"]}"#;
|
||||
@@ -1474,7 +1421,6 @@ mod tests {
|
||||
Some("https://proxy.corp.example.com")
|
||||
);
|
||||
|
||||
// Client builds successfully with the proxy endpoint set.
|
||||
let client = WebFetchClient::new(¶ms, None);
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
@@ -1486,8 +1432,6 @@ mod tests {
|
||||
assert!(params.proxy_endpoint.is_none());
|
||||
}
|
||||
|
||||
// ── HTML cleaning (scraper-based) ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn clean_html_removes_boilerplate_tags() {
|
||||
let html = r#"<body><nav>Menu</nav><header>Header</header><main>Content</main><footer>Footer</footer></body>"#;
|
||||
@@ -1516,8 +1460,6 @@ mod tests {
|
||||
assert!(!cleaned.contains("Menu"));
|
||||
}
|
||||
|
||||
// ── Image content type detection ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_image_detects_image_types() {
|
||||
assert!(is_image("image/png"));
|
||||
@@ -1543,8 +1485,6 @@ mod tests {
|
||||
assert!(!is_image("application/octet-stream"));
|
||||
}
|
||||
|
||||
// ── Video content type detection ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_video_detects_video_types() {
|
||||
assert!(is_video("video/mp4"));
|
||||
@@ -1562,8 +1502,6 @@ mod tests {
|
||||
assert!(!is_video("audio/mpeg"));
|
||||
}
|
||||
|
||||
// ── Magic byte validation ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn magic_bytes_valid_png() {
|
||||
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
@@ -1625,8 +1563,6 @@ mod tests {
|
||||
assert!(validate_media_magic_bytes("video/x-custom", b"anything"));
|
||||
}
|
||||
|
||||
// ── Media extension mapping ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn media_extension_maps_known_types() {
|
||||
assert_eq!(media_extension("image/png"), "png");
|
||||
|
||||
@@ -52,7 +52,6 @@ pub struct WebFetchParams {
|
||||
register_resource!("kigi", "WebFetch", WebFetchParams);
|
||||
|
||||
// Keep defaults here so call-sites don't have to manage unwrapping.
|
||||
// Vars are still public following other conventions though.
|
||||
impl WebFetchParams {
|
||||
pub fn cache_ttl_secs(&self) -> Duration {
|
||||
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
|
||||
|
||||
@@ -10,10 +10,6 @@ use url::Url;
|
||||
|
||||
use crate::types::output::WebFetchOutput;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Domain normalization
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Canonical form for domain comparison: trim whitespace, strip trailing
|
||||
/// slashes and dots, remove `www.` prefix, and lowercase.
|
||||
pub fn normalize_domain(raw: &str) -> String {
|
||||
@@ -22,10 +18,6 @@ pub fn normalize_domain(raw: &str) -> String {
|
||||
s.to_lowercase()
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Precomputed host entry
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What a single host is allowed to serve.
|
||||
#[derive(Debug, Clone)]
|
||||
enum HostEntry {
|
||||
@@ -36,10 +28,6 @@ enum HostEntry {
|
||||
PathPrefixes(Vec<String>),
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// DomainMatcher
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Precomputed domain allowlist. Built once from the raw allowlist entries,
|
||||
/// provides O(1) host lookup + small linear scan over path prefixes.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -153,10 +141,6 @@ pub fn domain_from_url(raw_url: &str) -> Option<String> {
|
||||
.and_then(|u| u.host_str().map(normalize_domain))
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -165,8 +149,6 @@ mod tests {
|
||||
Url::parse(s).unwrap()
|
||||
}
|
||||
|
||||
// ── normalize_domain ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_strips_www_and_trailing_dot() {
|
||||
assert_eq!(normalize_domain("www.Example.COM."), "example.com");
|
||||
@@ -177,8 +159,6 @@ mod tests {
|
||||
assert_eq!(normalize_domain(" docs.rs "), "docs.rs");
|
||||
}
|
||||
|
||||
// ── Host-only entries ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn allows_listed_domain() {
|
||||
let m = DomainMatcher::new(&["docs.rs".into(), "Example.Com".into()]);
|
||||
@@ -219,8 +199,6 @@ mod tests {
|
||||
assert!(m.check(&url("https://react.dev./learn")).is_none());
|
||||
}
|
||||
|
||||
// ── Path-scoped entries ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn path_scoped_allows_matching_path() {
|
||||
let m = DomainMatcher::new(&["vercel.com/docs".into()]);
|
||||
@@ -287,8 +265,6 @@ mod tests {
|
||||
assert!(m.check(&url("https://example.com/anything")).is_none());
|
||||
}
|
||||
|
||||
// ── Multiple path prefixes per host ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn multiple_path_prefixes_per_host() {
|
||||
let m = DomainMatcher::new(&["github.com/org-a".into(), "github.com/org-b".into()]);
|
||||
@@ -314,8 +290,6 @@ mod tests {
|
||||
assert!(m.check(&url("https://github.com/anything")).is_none());
|
||||
}
|
||||
|
||||
// ── Model URL variants ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn model_url_variants() {
|
||||
let m = DomainMatcher::new(&[
|
||||
@@ -354,8 +328,6 @@ mod tests {
|
||||
assert!(m.check(&url("https://93.184.216.34/page")).is_some());
|
||||
}
|
||||
|
||||
// ── domain_from_url ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn domain_from_url_extracts_and_normalizes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Structured errors for the `web_fetch` tool.
|
||||
//! Structured errors for the `web_fetch` tool.
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -98,7 +98,7 @@ mod tests {
|
||||
fn github_host_detection() {
|
||||
assert!(is_github_host("github.com"));
|
||||
assert!(is_github_host("api.github.com"));
|
||||
assert!(is_github_host("github.ghe.example.com")); // synthetic GHE-style
|
||||
assert!(is_github_host("github.ghe.example.com"));
|
||||
assert!(!is_github_host("ghe.example.com"));
|
||||
assert!(!is_github_host("internal-wiki.corp.example.com"));
|
||||
assert!(!is_github_host("gitlab.example.com"));
|
||||
@@ -109,9 +109,7 @@ mod tests {
|
||||
// Exercises the same `which` lookup `gh_available` uses, with a
|
||||
// controlled search dir so it doesn't depend on the test host's PATH.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// No gh in this dir yet.
|
||||
assert!(which::which_in("gh", Some(dir.path()), dir.path()).is_err());
|
||||
// Create an executable `gh`.
|
||||
let gh = dir.path().join("gh");
|
||||
std::fs::write(&gh, b"#!/bin/sh\nexit 0\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -100,7 +100,6 @@ mod tests {
|
||||
let second = client.get_or_rebuild().unwrap();
|
||||
let second_ptr = Arc::as_ptr(&second);
|
||||
|
||||
// After invalidation, we should get a different client instance.
|
||||
assert_ne!(first_ptr, second_ptr);
|
||||
}
|
||||
|
||||
@@ -110,7 +109,6 @@ mod tests {
|
||||
proxy_endpoint: Some("https://proxy.corp.example.com".into()),
|
||||
..Default::default()
|
||||
};
|
||||
// Should succeed — reqwest accepts the proxy URL.
|
||||
let client = HttpClient::new(¶ms);
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `web_fetch` tool — client-side URL fetching with improved HTML-to-markdown
|
||||
//! `web_fetch` tool — client-side URL fetching with HTML-to-markdown
|
||||
//! conversion and SSRF protection.
|
||||
//!
|
||||
//! Fetches a URL via `reqwest`, converts HTML to markdown via `htmd` (with
|
||||
@@ -21,10 +21,6 @@ pub use config::WebFetchParams;
|
||||
pub use domain::{DomainMatcher, domain_from_url};
|
||||
pub use error::WebFetchError;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Config enum (feature flag gating)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the `web_fetch` tool.
|
||||
///
|
||||
/// When `Enabled`, the tool is registered and a `WebFetchClient` is injected
|
||||
@@ -34,13 +30,11 @@ pub enum WebFetchConfig {
|
||||
#[default]
|
||||
Disabled,
|
||||
Enabled {
|
||||
/// Runtime parameters (allowed_domains, proxy_endpoint, timeouts, etc.)
|
||||
params: WebFetchParams,
|
||||
},
|
||||
}
|
||||
|
||||
impl WebFetchConfig {
|
||||
/// Returns `true` when the config is the `Enabled` variant.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
matches!(self, Self::Enabled { .. })
|
||||
}
|
||||
@@ -51,21 +45,12 @@ use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::resources::SessionFolder;
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Input
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct WebFetchInput {
|
||||
/// The URL to fetch content from.
|
||||
#[schemars(description = "The URL to fetch content from.")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WebFetchTool;
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 0.0.0.0 — unspecified address.
|
||||
if v4.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
@@ -57,7 +56,6 @@ pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
if v6.is_loopback() {
|
||||
return false;
|
||||
}
|
||||
// :: — unspecified.
|
||||
if v6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
@@ -88,7 +86,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
||||
host: String::new(),
|
||||
})?;
|
||||
|
||||
// If the host is already a literal IP, check it directly.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_blocked_ip(&ip) {
|
||||
return Err(WebFetchError::SsrfBlocked {
|
||||
@@ -99,7 +96,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// DNS resolution.
|
||||
let port = url.port_or_known_default().unwrap_or(443);
|
||||
let addr_str = format!("{host}:{port}");
|
||||
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&addr_str)
|
||||
@@ -129,8 +125,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── IPv4 blocking ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn blocks_rfc1918_10x() {
|
||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
||||
@@ -185,8 +179,6 @@ mod tests {
|
||||
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
|
||||
}
|
||||
|
||||
// ── IPv6 ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_link_local() {
|
||||
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
|
||||
@@ -211,8 +203,6 @@ mod tests {
|
||||
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
// ── check_ssrf integration ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_blocks_ip_literal_private() {
|
||||
let url = Url::parse("https://10.0.0.1/secret").unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `web_search` tool — new architecture (`Tool` trait).
|
||||
//! `web_search` tool.
|
||||
//!
|
||||
//! Calls the Kimi search service (PRD F5; kimi-cli `tools/web/search.py`
|
||||
//! parity). Reads the pre-constructed `WebSearchClient` from Resources
|
||||
@@ -10,10 +10,6 @@ use crate::types::output::WebSearchOutput;
|
||||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Input
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct WebSearchInput {
|
||||
#[schemars(description = "The query text to search for.")]
|
||||
@@ -37,10 +33,6 @@ pub struct WebSearchInput {
|
||||
const DEFAULT_LIMIT: u8 = 5;
|
||||
const MAX_LIMIT: u8 = 20;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tool implementation
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WebSearchTool;
|
||||
|
||||
|
||||
@@ -29,11 +29,9 @@ fn annotations(bash: &BashOutput) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// CONCISE foreground format: `Exit code: N [annotations]\n\nCommand output:\n\n```...```\n\nCommand completed.\n...`
|
||||
///
|
||||
/// When the process was killed by the harness or a kernel signal
|
||||
/// (see [`KillReason`]), the header reads
|
||||
/// `Exit code: killed (reason)` instead of `Exit code: -1 [signal=…]`.
|
||||
/// (see [`KillReason`]), the header reads `Exit code: killed (reason)`
|
||||
/// instead of `Exit code: -1 [signal=…]`.
|
||||
fn format_concise_foreground_prompt(bash: &BashOutput) -> String {
|
||||
let raw = String::from_utf8_lossy(&bash.output);
|
||||
let output_str = strip_str(&raw).to_string();
|
||||
@@ -57,7 +55,6 @@ fn format_concise_foreground_prompt(bash: &BashOutput) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// CONCISE backgrounded format: same as DEFAULT backgrounded (code-fenced partial output).
|
||||
fn format_concise_background_prompt(bash: &BashOutput) -> String {
|
||||
let raw = String::from_utf8_lossy(&bash.output);
|
||||
let output_str = strip_str(&raw).to_string();
|
||||
@@ -77,7 +74,7 @@ fn format_concise_background_prompt(bash: &BashOutput) -> String {
|
||||
/// Concise variant of `BashTool`.
|
||||
///
|
||||
/// Delegates to `BashTool::run()`, then overwrites `output_for_prompt` with
|
||||
/// the concise format. The `concise` concept lives entirely in this file.
|
||||
/// the concise format.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BashConciseTool;
|
||||
|
||||
@@ -262,14 +259,11 @@ mod tests {
|
||||
// output_for_prompt, then BashConciseTool overwrites with concise.
|
||||
// to_prompt_format() is a passthrough — it must NOT add another header.
|
||||
let mut bash = make_bash(0, "hello world\n");
|
||||
// Pre-bake DEFAULT (what BashTool::run() does)
|
||||
bash.output_for_prompt = crate::implementations::kigi::bash::format_default_prompt(&bash);
|
||||
assert!(bash.output_for_prompt.starts_with("exit: 0"));
|
||||
|
||||
// Concise post-processing (what BashConciseTool::run() does)
|
||||
bash.output_for_prompt = format_concise_foreground_prompt(&bash);
|
||||
|
||||
// to_prompt_format() is a passthrough
|
||||
let prompt = crate::types::output::ToolOutput::Bash(bash).to_prompt_format();
|
||||
assert!(
|
||||
prompt.starts_with("Exit code: 0"),
|
||||
|
||||
@@ -77,13 +77,14 @@ impl kigi_tool_runtime::Tool for ReadFileConciseTool {
|
||||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
// KigiConcise is not version-managed — always pass None.
|
||||
let cwd_override = ctx
|
||||
.extensions
|
||||
.get::<kigi_tool_runtime::Cwd>()
|
||||
.map(|c| c.0.clone());
|
||||
// `None`: the concise tool does not stream, so it needs no
|
||||
// text-path streamability signal (see `run_read_file`).
|
||||
// The two `None`s are contract_version (KigiConcise is not
|
||||
// version-managed) and streamable_out (the concise tool does not
|
||||
// stream, so it needs no text-path streamability signal); see
|
||||
// `run_read_file`.
|
||||
let result = run_read_file(input, cwd_override, None, resources, None).await?;
|
||||
|
||||
match result {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use crate::implementations::kigi::search_replace::{SearchReplaceInput, run_search_replace};
|
||||
|
||||
/// Concise description — no read-before-edit enforcement, simplified formatting guidance.
|
||||
const DESCRIPTION_CONCISE: &str = r#"Replace an exact string in a file.
|
||||
|
||||
- Do not include the "LINE_NUMBER→" prefixes from file reads in ${{ params.edit.old_string }} or ${{ params.edit.new_string }}; keep the exact indentation.
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Anchor convenience helpers and re-exports.
|
||||
//!
|
||||
//! This module re-exports the core types from [`super::scheme`] and provides
|
||||
//! helper functions for common anchor operations.
|
||||
|
||||
pub use super::scheme::{
|
||||
Anchor, AnchorScheme, CheckpointChain, ChunkFingerprint, ContentOnly, DEFAULT_SEARCH_RADIUS,
|
||||
@@ -9,10 +6,6 @@ pub use super::scheme::{
|
||||
};
|
||||
|
||||
/// Split file content into lines suitable for anchor generation.
|
||||
///
|
||||
/// Strips trailing newlines from each line (matching the convention used by
|
||||
/// `AnchorScheme::generate_anchors`). The returned `Vec<&str>` has one entry
|
||||
/// per logical line.
|
||||
pub fn split_lines(content: &str) -> Vec<&str> {
|
||||
if content.is_empty() {
|
||||
return vec![""];
|
||||
@@ -31,18 +24,12 @@ pub fn split_lines(content: &str) -> Vec<&str> {
|
||||
}
|
||||
|
||||
/// Generate anchors for file content using the given scheme.
|
||||
///
|
||||
/// Convenience wrapper: splits `content` into lines and calls
|
||||
/// `scheme.generate_anchors()`.
|
||||
pub fn generate_for_content(scheme: &dyn AnchorScheme, content: &str) -> Vec<Anchor> {
|
||||
let lines = split_lines(content);
|
||||
scheme.generate_anchors(&lines)
|
||||
}
|
||||
|
||||
/// Validate a parsed anchor against file content.
|
||||
///
|
||||
/// Convenience wrapper: splits `content` into lines and calls
|
||||
/// `scheme.validate()`.
|
||||
pub fn validate_against_content(
|
||||
scheme: &dyn AnchorScheme,
|
||||
anchor: &ParsedAnchor,
|
||||
@@ -53,9 +40,6 @@ pub fn validate_against_content(
|
||||
}
|
||||
|
||||
/// Search for a shifted anchor in file content.
|
||||
///
|
||||
/// Convenience wrapper: splits `content` into lines and calls
|
||||
/// `scheme.find_shifted()`.
|
||||
pub fn find_shifted_in_content(
|
||||
scheme: &dyn AnchorScheme,
|
||||
anchor: &ParsedAnchor,
|
||||
@@ -95,7 +79,8 @@ mod tests {
|
||||
let content = "line one\nline two\nline three\n";
|
||||
let scheme = ContentOnly::new();
|
||||
let anchors = generate_for_content(&scheme, content);
|
||||
assert_eq!(anchors.len(), 4); // 3 content lines + trailing empty
|
||||
// 3 content lines + trailing empty
|
||||
assert_eq!(anchors.len(), 4);
|
||||
assert_eq!(anchors[0].line, 1);
|
||||
assert_eq!(anchors[3].line, 4);
|
||||
}
|
||||
@@ -144,7 +129,7 @@ mod tests {
|
||||
// Insert a line at the top → "b" shifts from line 2 to line 3.
|
||||
let modified = "new\na\nb\nc\n";
|
||||
let parsed = ParsedAnchor {
|
||||
line: anchors[1].line, // originally line 2 ("b")
|
||||
line: anchors[1].line,
|
||||
local: anchors[1].local.clone(),
|
||||
context: None,
|
||||
};
|
||||
|
||||
@@ -29,16 +29,16 @@ use super::scheme::{
|
||||
/// Configuration for the benchmark harness.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BenchmarkConfig {
|
||||
/// Hash lengths to test (default: [2, 3]).
|
||||
/// Hash lengths to test.
|
||||
pub hash_lengths: Vec<usize>,
|
||||
|
||||
/// Chunk sizes to test for Candidate B (default: [8, 16, 32]).
|
||||
/// Chunk sizes to test for Candidate B.
|
||||
pub chunk_sizes: Vec<usize>,
|
||||
|
||||
/// Checkpoint intervals to test for Candidate C (default: [16, 32, 64]).
|
||||
/// Checkpoint intervals to test for Candidate C.
|
||||
pub checkpoint_intervals: Vec<usize>,
|
||||
|
||||
/// Search radius for shifted-anchor recovery (default: 15).
|
||||
/// Search radius for shifted-anchor recovery.
|
||||
pub search_radius: usize,
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ pub struct SchemeMetrics {
|
||||
/// Shifted-anchor recovery: not found.
|
||||
pub recovery_not_found: usize,
|
||||
|
||||
/// Collision count: distinct lines that produced the same anchor in the
|
||||
/// Distinct lines that produced the same anchor in the
|
||||
/// same file (local hash only).
|
||||
pub collision_count: usize,
|
||||
|
||||
@@ -270,7 +270,6 @@ pub fn run_benchmark(corpus: &[(&str, &str)], config: &BenchmarkConfig) -> Bench
|
||||
.map(|(_, content)| split_lines(content).len())
|
||||
.sum();
|
||||
|
||||
// Build scheme configurations to test.
|
||||
let schemes = build_scheme_configs(config);
|
||||
|
||||
for (label, scheme) in &schemes {
|
||||
@@ -354,7 +353,7 @@ fn standard_mutations(line_count: usize) -> Vec<(&'static str, Mutation)> {
|
||||
}
|
||||
|
||||
/// Estimate the read-amplification cost for a single validation under the
|
||||
/// given scheme, using the scheme's own `validation_window_lines()` method.
|
||||
/// given scheme.
|
||||
fn estimate_read_amp_lines(scheme: &dyn AnchorScheme, line_count: usize, line_idx: usize) -> usize {
|
||||
scheme.validation_window_lines(line_idx, line_count)
|
||||
}
|
||||
@@ -371,7 +370,7 @@ fn run_phase1_for_file(
|
||||
let line_count = original_lines.len();
|
||||
metrics.total_lines += line_count;
|
||||
|
||||
// --- Collision measurement ---
|
||||
// Collision measurement
|
||||
let anchors = scheme.generate_anchors(&original_lines);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for a in &anchors {
|
||||
@@ -384,7 +383,7 @@ fn run_phase1_for_file(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mutation scenarios ---
|
||||
// Mutation scenarios
|
||||
let mutations = standard_mutations(line_count);
|
||||
|
||||
for (_mutation_name, mutation) in &mutations {
|
||||
@@ -392,7 +391,6 @@ fn run_phase1_for_file(
|
||||
let mutation_result = apply_mutation(&mut mutated_lines, mutation);
|
||||
let mutated_refs: Vec<&str> = mutated_lines.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
// For each original anchor, validate against the mutated file.
|
||||
for (orig_idx, anchor) in anchors.iter().enumerate() {
|
||||
let parsed = ParsedAnchor {
|
||||
line: anchor.line,
|
||||
@@ -432,7 +430,8 @@ fn run_phase1_for_file(
|
||||
// line was shifted (not modified or deleted).
|
||||
if !reported_valid && let LineOutcome::Shifted { new_idx } = outcome {
|
||||
metrics.recovery_attempts += 1;
|
||||
let expected_line = new_idx + 1; // 1-based
|
||||
// 1-based
|
||||
let expected_line = new_idx + 1;
|
||||
|
||||
match scheme.find_shifted(&parsed, &mutated_refs, config.search_radius) {
|
||||
ShiftResult::Found { new_line } => {
|
||||
@@ -521,13 +520,11 @@ fn run_phase2_for_file(
|
||||
let traces = standard_traces(line_count);
|
||||
|
||||
for trace in &traces {
|
||||
// Start with the original file and its anchors.
|
||||
let mut current_lines: Vec<String> = original_lines.iter().map(|s| s.to_string()).collect();
|
||||
let mut current_anchors = scheme.generate_anchors(&original_lines);
|
||||
let mut needs_refresh = false;
|
||||
|
||||
for step in trace {
|
||||
// If the previous step required a re-read, regenerate anchors now.
|
||||
if needs_refresh {
|
||||
let refs: Vec<&str> = current_lines.iter().map(|s| s.as_str()).collect();
|
||||
current_anchors = scheme.generate_anchors(&refs);
|
||||
@@ -539,27 +536,22 @@ fn run_phase2_for_file(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Snapshot the anchor we want to probe.
|
||||
let probe_anchor = ParsedAnchor {
|
||||
line: current_anchors[probe_idx].line,
|
||||
local: current_anchors[probe_idx].local.clone(),
|
||||
context: current_anchors[probe_idx].context.clone(),
|
||||
};
|
||||
|
||||
// Apply the mutation.
|
||||
apply_mutation(&mut current_lines, &step.mutation);
|
||||
|
||||
// Validate the probed anchor against the mutated file.
|
||||
let refs: Vec<&str> = current_lines.iter().map(|s| s.as_str()).collect();
|
||||
let result = scheme.validate(&probe_anchor, &refs);
|
||||
|
||||
metrics.trace_steps += 1;
|
||||
if result == ValidationResult::Valid {
|
||||
metrics.trace_anchors_survived += 1;
|
||||
// Keep using existing anchors — no refresh needed.
|
||||
} else {
|
||||
metrics.trace_reread_required += 1;
|
||||
// Mark for refresh at the start of the next step.
|
||||
needs_refresh = true;
|
||||
}
|
||||
}
|
||||
@@ -650,7 +642,6 @@ struct Config3 {
|
||||
let config = BenchmarkConfig::default();
|
||||
let report = run_benchmark(&corpus, &config);
|
||||
|
||||
// Expected: hash_lengths.len() * (1 + chunk_sizes.len() + checkpoint_intervals.len())
|
||||
let expected = config.hash_lengths.len()
|
||||
* (1 + config.chunk_sizes.len() + config.checkpoint_intervals.len());
|
||||
assert_eq!(report.schemes.len(), expected);
|
||||
@@ -696,7 +687,7 @@ struct Config3 {
|
||||
search_radius: DEFAULT_SEARCH_RADIUS,
|
||||
};
|
||||
let report = run_benchmark(&corpus, &config);
|
||||
assert_eq!(report.schemes.len(), 2); // A + B
|
||||
assert_eq!(report.schemes.len(), 2);
|
||||
let b = &report.schemes[1];
|
||||
assert!(
|
||||
b.false_stale > 0,
|
||||
@@ -716,7 +707,7 @@ struct Config3 {
|
||||
search_radius: DEFAULT_SEARCH_RADIUS,
|
||||
};
|
||||
let report = run_benchmark(&corpus, &config);
|
||||
assert_eq!(report.schemes.len(), 2); // A + B
|
||||
assert_eq!(report.schemes.len(), 2);
|
||||
|
||||
let a = &report.schemes[0];
|
||||
let b = &report.schemes[1];
|
||||
@@ -864,8 +855,6 @@ struct Config3 {
|
||||
|
||||
#[test]
|
||||
fn recovery_correctness_tracked() {
|
||||
// Verify that recovery_correct + recovery_wrong + recovery_ambiguous
|
||||
// + recovery_not_found == recovery_attempts.
|
||||
let corpus = test_corpus();
|
||||
let config = BenchmarkConfig {
|
||||
hash_lengths: vec![3],
|
||||
|
||||
@@ -91,7 +91,6 @@ impl Default for HashlineSchemeParams {
|
||||
}
|
||||
|
||||
impl HashlineSchemeParams {
|
||||
/// Validate the parameters. Returns an error message if invalid.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self.scheme.as_str() {
|
||||
"chunk" | "content_only" => {}
|
||||
@@ -111,8 +110,6 @@ impl HashlineSchemeParams {
|
||||
}
|
||||
|
||||
/// Generate example anchor strings for use in tool descriptions.
|
||||
/// Returns (single_anchor, line_with_anchor) based on the configured scheme.
|
||||
/// Returns `(anchor, read_line1, read_line2, grep_match, grep_context)`.
|
||||
pub fn example_anchors(&self) -> ExampleAnchors {
|
||||
let len = self.hash_len.clamp(1, 4);
|
||||
let hash = &"abcd"[..len];
|
||||
@@ -169,7 +166,7 @@ impl HashlineSchemeParams {
|
||||
crate::types::definition::ToolDefinition::function(client_name, Some(description), schema)
|
||||
}
|
||||
|
||||
/// Validate and build the anchor scheme. Returns an error if params are invalid.
|
||||
/// Validate and build the anchor scheme.
|
||||
pub fn build_scheme(&self) -> Result<Box<dyn AnchorScheme>, String> {
|
||||
self.validate()?;
|
||||
Ok(match self.scheme.as_str() {
|
||||
@@ -320,22 +317,26 @@ mod tests {
|
||||
fn render_does_not_panic_on_invalid_hash_len() {
|
||||
let params = HashlineSchemeParams {
|
||||
scheme: "chunk".to_owned(),
|
||||
hash_len: 100, // invalid but clamped
|
||||
// invalid but clamped
|
||||
hash_len: 100,
|
||||
chunk_size: 8,
|
||||
};
|
||||
let rendered = params.render_description("{example_anchor}");
|
||||
assert_eq!(rendered, "22:abcd:rstu"); // clamped to 4
|
||||
// clamped to 4
|
||||
assert_eq!(rendered, "22:abcd:rstu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_does_not_panic_on_zero_hash_len() {
|
||||
let params = HashlineSchemeParams {
|
||||
scheme: "chunk".to_owned(),
|
||||
hash_len: 0, // invalid but clamped
|
||||
// invalid but clamped
|
||||
hash_len: 0,
|
||||
chunk_size: 8,
|
||||
};
|
||||
let rendered = params.render_description("{example_anchor}");
|
||||
assert_eq!(rendered, "22:a:r"); // clamped to 1
|
||||
// clamped to 1
|
||||
assert_eq!(rendered, "22:a:r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,10 +70,9 @@ fn detect_anchor_prefix_in_content(content: &str) -> Option<usize> {
|
||||
fn anchor_content_error(op_label: &str, content: &str, line_num: usize) -> HashlineEditError {
|
||||
let offending_line = content.lines().nth(line_num - 1).unwrap_or("").to_owned();
|
||||
|
||||
// Build a small context snippet (up to 3 lines around the offending line).
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let ctx_start = line_num.saturating_sub(1).saturating_sub(1); // 1 line before (0-based)
|
||||
let ctx_end = (line_num + 1).min(lines.len()); // 1 line after
|
||||
let ctx_start = line_num.saturating_sub(1).saturating_sub(1);
|
||||
let ctx_end = (line_num + 1).min(lines.len());
|
||||
let context: String = (ctx_start..ctx_end)
|
||||
.map(|i| {
|
||||
let marker = if i + 1 == line_num { ">>>" } else { " " };
|
||||
@@ -122,7 +121,6 @@ struct ResolvedOp {
|
||||
/// Result of `apply_edits`: the output to return to the caller, plus the new
|
||||
/// file content on success (to be written to disk by the tool layer).
|
||||
pub(crate) struct ApplyResult {
|
||||
/// The structured output (success or error).
|
||||
pub output: HashlineEditOutput,
|
||||
/// The new file content string. `Some` only when `output` is
|
||||
/// `EditsApplied`; `None` on error.
|
||||
@@ -283,7 +281,6 @@ pub(crate) fn apply_edits(
|
||||
let new_content = result_lines.join("\n");
|
||||
let total_new_lines = split_lines(&new_content).len();
|
||||
|
||||
// Sort edit regions top-down and merge nearby ones.
|
||||
edit_regions.sort_by_key(|r| r.0);
|
||||
let snippet = build_snippet(&new_content, &edit_regions, total_new_lines, scheme);
|
||||
let snippet_start_line = edit_regions
|
||||
@@ -336,7 +333,7 @@ fn build_snippet(
|
||||
.unwrap()
|
||||
.min(total_new_lines);
|
||||
|
||||
// If the span is small enough, emit one contiguous snippet (original behavior).
|
||||
// Span small enough: emit one contiguous snippet.
|
||||
if global_end - global_start <= MAX_CONTIGUOUS_SNIPPET {
|
||||
let (snippet, _raw) = format_hashline_content(
|
||||
new_content,
|
||||
@@ -361,7 +358,6 @@ fn build_snippet(
|
||||
merged.push((ctx_start, ctx_end));
|
||||
}
|
||||
|
||||
// Build per-region snippets separated by gap markers.
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut prev_end: usize = 0;
|
||||
|
||||
@@ -420,16 +416,16 @@ fn resolve_op(
|
||||
ambiguous_candidates: vec![],
|
||||
});
|
||||
}
|
||||
e + 1 // exclusive end
|
||||
e + 1
|
||||
}
|
||||
None => start + 1, // single line
|
||||
None => start + 1,
|
||||
};
|
||||
|
||||
if let Some(line_num) = detect_anchor_prefix_in_content(content) {
|
||||
return Err(anchor_content_error("replace", content, line_num));
|
||||
}
|
||||
let new_lines: Vec<String> = if content.is_empty() {
|
||||
vec![] // delete
|
||||
vec![]
|
||||
} else {
|
||||
content.lines().map(|l| l.to_owned()).collect()
|
||||
};
|
||||
@@ -464,7 +460,8 @@ fn resolve_op(
|
||||
return Err(anchor_content_error("insert_after", content, line_num));
|
||||
}
|
||||
let new_lines: Vec<String> = if content.is_empty() {
|
||||
vec![String::new()] // blank line
|
||||
// Empty content inserts a blank line.
|
||||
vec![String::new()]
|
||||
} else {
|
||||
content.lines().map(|l| l.to_owned()).collect()
|
||||
};
|
||||
@@ -472,7 +469,7 @@ fn resolve_op(
|
||||
Ok(ResolvedOp {
|
||||
original_idx,
|
||||
start: insert_at,
|
||||
end: insert_at, // insertion: start == end
|
||||
end: insert_at,
|
||||
new_lines,
|
||||
})
|
||||
}
|
||||
@@ -575,7 +572,7 @@ fn validate_anchor(
|
||||
let result = scheme.validate(&parsed, lines);
|
||||
|
||||
match result {
|
||||
ValidationResult::Valid => Ok(parsed.line - 1), // 0-based
|
||||
ValidationResult::Valid => Ok(parsed.line - 1),
|
||||
|
||||
ValidationResult::OutOfRange => Err(HashlineEditError {
|
||||
error: HashlineEditErrorKind::AnchorNotFound,
|
||||
@@ -693,7 +690,7 @@ fn check_overlaps(ops: &[ResolvedOp]) -> Option<HashlineEditError> {
|
||||
// strictly inside a replacement span (start <= insert_at < end).
|
||||
for op in ops {
|
||||
if op.start != op.end {
|
||||
continue; // not an insertion
|
||||
continue;
|
||||
}
|
||||
let insert_at = op.start;
|
||||
for &(rs, re, r_idx) in &ranges {
|
||||
@@ -796,7 +793,7 @@ mod tests {
|
||||
fn point_replace() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(), // " let x = 1;"
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: None,
|
||||
content: " let x = 999;".to_owned(),
|
||||
}];
|
||||
@@ -817,13 +814,12 @@ mod tests {
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: None,
|
||||
content: String::new(), // delete
|
||||
content: String::new(),
|
||||
}];
|
||||
|
||||
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
|
||||
HashlineEditOutput::EditsApplied(result) => {
|
||||
assert_eq!(result.applied, 1);
|
||||
// Deleted line should not appear in snippet.
|
||||
assert!(!result.snippet.contains("let x = 1"));
|
||||
}
|
||||
HashlineEditOutput::Error(e) => panic!("Expected success, got error: {}", e.message),
|
||||
@@ -834,8 +830,8 @@ mod tests {
|
||||
fn range_replace() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(), // " let x = 1;"
|
||||
end_anchor: Some(anchors[2].clone()), // " let y = 2;"
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[2].clone()),
|
||||
content: " let z = 42;".to_owned(),
|
||||
}];
|
||||
|
||||
@@ -915,15 +911,14 @@ mod tests {
|
||||
#[test]
|
||||
fn batch_ordering_bottom_up() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
// Two non-overlapping replacements at lines 2 and 4.
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(), // line 2
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: None,
|
||||
content: " let x = 100;".to_owned(),
|
||||
},
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[3].clone(), // line 4
|
||||
anchor: anchors[3].clone(),
|
||||
end_anchor: None,
|
||||
content: " println!(\"changed\");".to_owned(),
|
||||
},
|
||||
@@ -944,7 +939,7 @@ mod tests {
|
||||
// Build a file large enough that edits at opposite ends exceed MAX_CONTIGUOUS_SNIPPET.
|
||||
let line_count = 200;
|
||||
let mut file_lines: Vec<String> = (0..line_count).map(|i| format!("line_{i}")).collect();
|
||||
file_lines.push(String::new()); // trailing newline
|
||||
file_lines.push(String::new());
|
||||
let content = file_lines.join("\n");
|
||||
|
||||
let anchors = anchors_for(&content);
|
||||
@@ -995,12 +990,12 @@ mod tests {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(), // line 2
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: None,
|
||||
content: " let x = 99;".to_owned(),
|
||||
},
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[3].clone(), // line 4
|
||||
anchor: anchors[3].clone(),
|
||||
end_anchor: None,
|
||||
content: " println!(\"hi\");".to_owned(),
|
||||
},
|
||||
@@ -1143,11 +1138,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()), // lines 2-4
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "a".to_owned(),
|
||||
},
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[2].clone(), // line 3 — overlaps
|
||||
anchor: anchors[2].clone(),
|
||||
end_anchor: None,
|
||||
content: "b".to_owned(),
|
||||
},
|
||||
@@ -1209,11 +1204,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()), // lines 2-4
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "a".to_owned(),
|
||||
},
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[2].clone(), // line 3 — overlaps
|
||||
anchor: anchors[2].clone(),
|
||||
end_anchor: None,
|
||||
content: "b".to_owned(),
|
||||
},
|
||||
@@ -1231,8 +1226,8 @@ mod tests {
|
||||
fn end_before_start_error() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[3].clone(), // line 4
|
||||
end_anchor: Some(anchors[1].clone()), // line 2 — before start
|
||||
anchor: anchors[3].clone(),
|
||||
end_anchor: Some(anchors[1].clone()),
|
||||
content: "x".to_owned(),
|
||||
}];
|
||||
|
||||
@@ -1272,7 +1267,6 @@ mod tests {
|
||||
|
||||
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
|
||||
HashlineEditOutput::EditsApplied(result) => {
|
||||
// Snippet should have the hashline format with anchors.
|
||||
assert!(result.snippet.contains('→'));
|
||||
assert!(result.snippet.contains(':'));
|
||||
}
|
||||
@@ -1294,7 +1288,6 @@ mod tests {
|
||||
// "line3" should appear right after "line2" in the snippet,
|
||||
// without an intervening blank line.
|
||||
assert!(result.snippet.contains("line3"));
|
||||
// Count content lines in snippet (excluding "lines not shown").
|
||||
let content_lines: Vec<&str> = result
|
||||
.snippet
|
||||
.lines()
|
||||
@@ -1341,18 +1334,17 @@ mod tests {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[1].clone(), // after line 2
|
||||
anchor: anchors[1].clone(),
|
||||
content: " // first".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[1].clone(), // same anchor
|
||||
anchor: anchors[1].clone(),
|
||||
content: " // second".to_owned(),
|
||||
},
|
||||
];
|
||||
|
||||
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
|
||||
HashlineEditOutput::EditsApplied(result) => {
|
||||
// "first" should appear before "second" in the output.
|
||||
let first_pos = result.snippet.find("// first");
|
||||
let second_pos = result.snippet.find("// second");
|
||||
assert!(
|
||||
@@ -1374,11 +1366,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()), // lines 2-4
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "replaced".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[2].clone(), // line 3 — inside replaced span
|
||||
anchor: anchors[2].clone(),
|
||||
content: "inserted".to_owned(),
|
||||
},
|
||||
];
|
||||
@@ -1399,11 +1391,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()), // 0-based [1..4)
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "replaced".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: "0:".to_owned(), // inserts at idx 0, before range
|
||||
anchor: "0:".to_owned(),
|
||||
content: "header".to_owned(),
|
||||
},
|
||||
];
|
||||
@@ -1424,11 +1416,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()), // 0-based [1..4)
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "replaced".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[0].clone(), // after line 1 → insert_at=1 = range.start
|
||||
anchor: anchors[0].clone(),
|
||||
content: "at_range_start".to_owned(),
|
||||
},
|
||||
];
|
||||
@@ -1450,11 +1442,11 @@ mod tests {
|
||||
let ops = vec![
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[2].clone()), // lines 2-3
|
||||
end_anchor: Some(anchors[2].clone()),
|
||||
content: "replaced".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[2].clone(), // insert after line 3 — at idx 3, which is exclusive end
|
||||
anchor: anchors[2].clone(),
|
||||
content: "after_range".to_owned(),
|
||||
},
|
||||
];
|
||||
@@ -1470,7 +1462,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Stateless range policy tests ----------------------------------------
|
||||
// Stateless range policy tests.
|
||||
|
||||
#[test]
|
||||
fn large_range_produces_warning() {
|
||||
@@ -1527,7 +1519,7 @@ mod tests {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[2].clone()), // 2-line range
|
||||
end_anchor: Some(anchors[2].clone()),
|
||||
content: "replaced".to_owned(),
|
||||
}];
|
||||
|
||||
@@ -1539,12 +1531,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Recovery tests -----------------------------------------------------
|
||||
// Recovery tests.
|
||||
|
||||
#[test]
|
||||
fn shifted_recovery_after_insert_above() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let anchor_line2 = anchors[1].clone(); // " let x = 1;"
|
||||
let anchor_line2 = anchors[1].clone();
|
||||
|
||||
// Insert 2 lines at the top → line 2 shifts to line 4.
|
||||
let mut shifted_lines: Vec<&str> = vec!["// new1", "// new2"];
|
||||
@@ -1587,7 +1579,7 @@ mod tests {
|
||||
#[test]
|
||||
fn shifted_recovery_after_delete_above() {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let anchor_line4 = anchors[3].clone(); // " println!(...)"
|
||||
let anchor_line4 = anchors[3].clone();
|
||||
|
||||
// Delete line 1 → line 4 shifts to line 3.
|
||||
let mut lines: Vec<&str> = SAMPLE.lines().collect();
|
||||
@@ -1655,7 +1647,7 @@ mod tests {
|
||||
let content = lines.join("\n");
|
||||
|
||||
let anchors = anchors_for(&content);
|
||||
let anchor_line5 = anchors[4].clone(); // one of the repeated lines
|
||||
let anchor_line5 = anchors[4].clone();
|
||||
|
||||
// Insert a line at top → all repeated lines shift.
|
||||
let mut shifted = vec!["// inserted".to_owned()];
|
||||
@@ -1770,7 +1762,7 @@ mod tests {
|
||||
|
||||
// Get the FULL anchor (with chunk context) for line 5.
|
||||
let full_anchors = anchors_for(&original);
|
||||
let full_anchor = full_anchors[4].clone(); // line 5, has :local:context
|
||||
let full_anchor = full_anchors[4].clone();
|
||||
|
||||
// Insert exactly 8 new lines at the top.
|
||||
// Line 5 → position 13. Chunk at [8,16) in the shifted file =
|
||||
@@ -1800,7 +1792,7 @@ mod tests {
|
||||
"Recovery must find shifted line with full chunk anchor. Error: {}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(err.shifted_to.unwrap(), 13); // line 5 + 8 = line 13
|
||||
assert_eq!(err.shifted_to.unwrap(), 13);
|
||||
let fresh = err.shifted_anchor.expect("shifted_anchor must be present");
|
||||
assert!(err.message.contains("Retry"));
|
||||
|
||||
@@ -1864,18 +1856,15 @@ mod tests {
|
||||
let result = apply_edits(content, &ops, &test_path(), &*test_scheme());
|
||||
let new = result.new_content.expect("should succeed");
|
||||
|
||||
// A blank line should appear between line1 and line2.
|
||||
assert!(
|
||||
new.contains("line1\n\nline2"),
|
||||
"empty content should insert a blank line, got: {new}"
|
||||
);
|
||||
|
||||
// The detail should reflect the blank line insertion.
|
||||
assert_eq!(result.edit_details.len(), 1);
|
||||
assert_eq!(result.edit_details[0].old_text, "");
|
||||
assert_eq!(result.edit_details[0].new_text, "");
|
||||
|
||||
// The snippet should include the blank line with a fresh anchor.
|
||||
match result.output {
|
||||
HashlineEditOutput::EditsApplied(applied) => {
|
||||
assert!(
|
||||
@@ -1908,8 +1897,8 @@ mod tests {
|
||||
let anchors = anchors_for(content);
|
||||
|
||||
let ops = vec![HashlineOp::Replace {
|
||||
anchor: anchors[1].clone(), // line2
|
||||
end_anchor: Some(anchors[3].clone()), // line4
|
||||
anchor: anchors[1].clone(),
|
||||
end_anchor: Some(anchors[3].clone()),
|
||||
content: "replaced_range".to_owned(),
|
||||
}];
|
||||
|
||||
@@ -1925,11 +1914,11 @@ mod tests {
|
||||
let anchors = anchors_for(SAMPLE);
|
||||
let ops = vec![
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[0].clone(), // after "fn main() {"
|
||||
anchor: anchors[0].clone(),
|
||||
content: " // comment".to_owned(),
|
||||
},
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[3].clone(), // println line
|
||||
anchor: anchors[3].clone(),
|
||||
end_anchor: None,
|
||||
content: " println!(\"changed\");".to_owned(),
|
||||
},
|
||||
@@ -1937,10 +1926,8 @@ mod tests {
|
||||
|
||||
let result = apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme());
|
||||
assert_eq!(result.edit_details.len(), 2);
|
||||
// Insert: no old content
|
||||
assert_eq!(result.edit_details[0].old_text, "");
|
||||
assert_eq!(result.edit_details[0].new_text, " // comment");
|
||||
// Replace: old content is the println line
|
||||
assert_eq!(
|
||||
result.edit_details[1].old_text,
|
||||
" println!(\"{x} {y}\");"
|
||||
@@ -1951,7 +1938,7 @@ mod tests {
|
||||
);
|
||||
// New line should account for the insertion shift
|
||||
assert_eq!(result.edit_details[1].old_line, 4);
|
||||
assert_eq!(result.edit_details[1].new_line, 5); // shifted by 1
|
||||
assert_eq!(result.edit_details[1].new_line, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1999,7 +1986,6 @@ mod tests {
|
||||
let result = apply_edits(&content, &ops, &test_path(), &*test_scheme());
|
||||
assert_eq!(result.edit_details.len(), 2);
|
||||
|
||||
// Each detail should contain only the affected content
|
||||
assert_eq!(result.edit_details[0].old_text, "");
|
||||
assert_eq!(result.edit_details[0].new_text, "INSERTED");
|
||||
assert_eq!(result.edit_details[1].old_text, "line_194");
|
||||
@@ -2123,7 +2109,7 @@ mod tests {
|
||||
result.is_ok(),
|
||||
"unique hash suffix should recover: {suffix}"
|
||||
);
|
||||
assert_eq!(result.unwrap(), 2); // 0-based line index
|
||||
assert_eq!(result.unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,12 +67,10 @@ Follow-up edits:
|
||||
- Never fabricate or modify anchors — only use exact anchors as returned by
|
||||
previous read, grep, or edit calls."#;
|
||||
|
||||
/// `hashline_edit` tool — edits files using anchor references.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HashlineEditTool;
|
||||
|
||||
impl HashlineEditTool {
|
||||
/// Build a `FileNotFound` output with enriched path hints (if enabled).
|
||||
async fn file_not_found(
|
||||
display_path: &std::path::Path,
|
||||
joined_path: &std::path::Path,
|
||||
@@ -118,9 +116,8 @@ fn to_search_replace(
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let ctx_count = 3;
|
||||
let old_idx = d.old_line.saturating_sub(1); // 0-based
|
||||
let old_idx = d.old_line.saturating_sub(1);
|
||||
|
||||
// Lines before the edit in the old file.
|
||||
let before_start = old_idx.saturating_sub(ctx_count);
|
||||
let context_before = if before_start < old_idx {
|
||||
let mut cb = old_lines[before_start..old_idx].join("\n");
|
||||
@@ -130,7 +127,6 @@ fn to_search_replace(
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Lines after the edit in the old file.
|
||||
let old_text_line_count = if d.old_text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
@@ -321,8 +317,6 @@ impl kigi_tool_runtime::Tool for HashlineEditTool {
|
||||
let path = match crate::util::fs::try_canonicalize(&joined_path).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
// Try unicode-confusable resolution before giving up.
|
||||
// Used in search_replace.
|
||||
let resolved = crate::util::try_resolve_unicode_filename(&joined_path).await;
|
||||
if let Some(m) = resolved {
|
||||
m.resolved_path
|
||||
@@ -374,7 +368,6 @@ impl kigi_tool_runtime::Tool for HashlineEditTool {
|
||||
}
|
||||
};
|
||||
|
||||
// Read current file content.
|
||||
let file_bytes = match fs.read_file(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -500,7 +493,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration: same-anchor insertions preserve request order on disk.
|
||||
#[tokio::test]
|
||||
async fn disk_preserves_same_anchor_insert_order() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -515,11 +507,11 @@ mod tests {
|
||||
file_path: "test.txt".to_string(),
|
||||
edits: vec![
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[0].clone(), // after line 1
|
||||
anchor: anchors[0].clone(),
|
||||
content: "first_insert".to_owned(),
|
||||
},
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[0].clone(), // same anchor
|
||||
anchor: anchors[0].clone(),
|
||||
content: "second_insert".to_owned(),
|
||||
},
|
||||
],
|
||||
@@ -548,7 +540,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration: EOF append on file with trailing newline.
|
||||
#[tokio::test]
|
||||
async fn disk_eof_append_no_extra_blank() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -583,7 +574,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The erased tool must produce ToolOutput::SearchReplace, not a custom variant.
|
||||
#[tokio::test]
|
||||
async fn output_is_tool_output_search_replace() {
|
||||
use crate::types::output::ToolOutput;
|
||||
@@ -610,7 +600,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Convert to ToolOutput — must be the SearchReplace variant.
|
||||
let tool_output: ToolOutput = result.into();
|
||||
assert!(
|
||||
matches!(tool_output, ToolOutput::SearchReplace(_)),
|
||||
@@ -618,8 +607,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Diff detail tests (multi-edit compactness) -------------------------
|
||||
|
||||
fn test_scheme() -> Box<dyn crate::implementations::kigi_hashline::scheme::AnchorScheme> {
|
||||
crate::implementations::kigi_hashline::config::HashlineSchemeParams::default()
|
||||
.build_scheme()
|
||||
@@ -637,12 +624,10 @@ mod tests {
|
||||
let anchors = anchors_for(&content);
|
||||
|
||||
let ops = vec![
|
||||
// Insert near the top — changes line count.
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[4].clone(),
|
||||
content: "INSERTED_LINE".to_owned(),
|
||||
},
|
||||
// Replace near the bottom.
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[90].clone(),
|
||||
end_anchor: None,
|
||||
@@ -664,7 +649,6 @@ mod tests {
|
||||
|
||||
match sr {
|
||||
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
|
||||
// Should have exactly 2 details (one per edit).
|
||||
assert_eq!(
|
||||
applied.edits.details.len(),
|
||||
2,
|
||||
@@ -672,15 +656,12 @@ mod tests {
|
||||
applied.edits.details.len()
|
||||
);
|
||||
|
||||
// Detail 0: insertion (empty old, "INSERTED_LINE" new)
|
||||
assert_eq!(applied.edits.details[0].old_string, "");
|
||||
assert_eq!(applied.edits.details[0].new_string, "INSERTED_LINE");
|
||||
|
||||
// Detail 1: replacement
|
||||
assert_eq!(applied.edits.details[1].old_string, "line_90");
|
||||
assert_eq!(applied.edits.details[1].new_string, "REPLACED_LINE");
|
||||
|
||||
// Total detail size should be very small — NOT the entire file.
|
||||
let total_detail_bytes: usize = applied
|
||||
.edits
|
||||
.details
|
||||
@@ -731,8 +712,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scattered edits across a large file must produce compact per-edit details,
|
||||
/// not a diff that spans the entire file.
|
||||
#[test]
|
||||
fn scattered_edits_details_total_size_bounded() {
|
||||
let line_count = 500;
|
||||
@@ -754,7 +733,7 @@ mod tests {
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[498].clone(),
|
||||
end_anchor: None,
|
||||
content: String::new(), // delete
|
||||
content: String::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -774,21 +753,17 @@ mod tests {
|
||||
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
|
||||
assert_eq!(applied.edits.details.len(), 3);
|
||||
|
||||
// Total detail content should be small compared to the 500-line file.
|
||||
let total_detail_bytes: usize = applied
|
||||
.edits
|
||||
.details
|
||||
.iter()
|
||||
.map(|d| d.old_string.len() + d.new_string.len())
|
||||
.sum();
|
||||
// With old code, this would be thousands of bytes due to positional diff.
|
||||
// With new code, it's just the affected lines.
|
||||
assert!(
|
||||
total_detail_bytes < 200,
|
||||
"Details should be compact for scattered edits, got {total_detail_bytes} bytes"
|
||||
);
|
||||
|
||||
// Verify each detail has the correct content.
|
||||
assert_eq!(applied.edits.details[0].old_string, "");
|
||||
assert_eq!(applied.edits.details[0].new_string, "TOP_INSERT");
|
||||
assert_eq!(applied.edits.details[1].old_string, "line_250");
|
||||
@@ -806,12 +781,10 @@ mod tests {
|
||||
let anchors = anchors_for(content);
|
||||
|
||||
let ops = vec![
|
||||
// Insert after line 1 — adds a line, shifting everything below by 1.
|
||||
HashlineOp::InsertAfter {
|
||||
anchor: anchors[0].clone(),
|
||||
content: "inserted".to_owned(),
|
||||
},
|
||||
// Replace line 4 — in the new file, this is at line 5 due to the insertion.
|
||||
HashlineOp::Replace {
|
||||
anchor: anchors[3].clone(),
|
||||
end_anchor: None,
|
||||
@@ -835,17 +808,15 @@ mod tests {
|
||||
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
|
||||
assert_eq!(applied.edits.details.len(), 2);
|
||||
|
||||
// First edit: insert after line 1
|
||||
let d0 = &applied.edits.details[0];
|
||||
assert_eq!(d0.old_string, ""); // insertion has no old content
|
||||
assert_eq!(d0.old_string, "");
|
||||
assert_eq!(d0.new_string, "inserted");
|
||||
|
||||
// Second edit: replace line 4
|
||||
let d1 = &applied.edits.details[1];
|
||||
assert_eq!(d1.old_line, 4); // line 4 in old file
|
||||
assert_eq!(d1.old_line, 4);
|
||||
assert_eq!(d1.old_string, "line4");
|
||||
assert_eq!(d1.new_string, "replaced");
|
||||
assert_eq!(d1.new_line, 5); // shifted to line 5 in new file
|
||||
assert_eq!(d1.new_line, 5);
|
||||
}
|
||||
_ => panic!("Expected EditsApplied"),
|
||||
}
|
||||
@@ -872,7 +843,6 @@ mod tests {
|
||||
|
||||
match sr {
|
||||
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
|
||||
// Write op now produces a single whole-file detail for TUI diffing.
|
||||
assert_eq!(
|
||||
applied.edits.details.len(),
|
||||
1,
|
||||
@@ -887,8 +857,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Context lines tests (TUI rendering) ---------------------------------
|
||||
|
||||
const RENDER_SAMPLE: &str = "fn main() {\n let x = 1;\n let y = 2;\n let z = x + y;\n println!(\"sum = {z}\");\n if z > 2 {\n println!(\"big\");\n }\n let w = z * 2;\n println!(\"double = {w}\");\n}\n";
|
||||
|
||||
fn apply_and_convert(
|
||||
@@ -914,7 +882,6 @@ mod tests {
|
||||
#[test]
|
||||
fn context_lines_for_single_replace() {
|
||||
let anchors = anchors_for(RENDER_SAMPLE);
|
||||
// Replace line 5: println!("sum = {z}");
|
||||
let applied = apply_and_convert(
|
||||
RENDER_SAMPLE,
|
||||
vec![HashlineOp::Replace {
|
||||
@@ -928,7 +895,6 @@ mod tests {
|
||||
assert_eq!(d.old_string, " println!(\"sum = {z}\");");
|
||||
assert_eq!(d.new_string, " println!(\"total = {z}\");");
|
||||
|
||||
// 3 context lines before (lines 2-4).
|
||||
assert!(
|
||||
d.context_before.contains("let y = 2;"),
|
||||
"context_before should have line 3: {}",
|
||||
@@ -940,7 +906,6 @@ mod tests {
|
||||
d.context_before
|
||||
);
|
||||
|
||||
// 3 context lines after (lines 6-8).
|
||||
assert!(
|
||||
d.context_after.contains("if z > 2"),
|
||||
"context_after should have line 6: {}",
|
||||
@@ -956,7 +921,6 @@ mod tests {
|
||||
#[test]
|
||||
fn context_lines_for_insert_after() {
|
||||
let anchors = anchors_for(RENDER_SAMPLE);
|
||||
// Insert after line 3: let y = 2;
|
||||
let applied = apply_and_convert(
|
||||
RENDER_SAMPLE,
|
||||
vec![HashlineOp::InsertAfter {
|
||||
@@ -969,7 +933,6 @@ mod tests {
|
||||
assert_eq!(d.old_string, "");
|
||||
assert_eq!(d.new_string, " let a = 99;");
|
||||
|
||||
// Context before should include lines leading up to insertion point.
|
||||
assert!(
|
||||
d.context_before.contains("let x = 1;"),
|
||||
"context_before: {}",
|
||||
@@ -981,7 +944,6 @@ mod tests {
|
||||
d.context_before
|
||||
);
|
||||
|
||||
// Context after should include lines after insertion point.
|
||||
assert!(
|
||||
d.context_after.contains("let z = x + y;"),
|
||||
"context_after: {}",
|
||||
@@ -992,7 +954,6 @@ mod tests {
|
||||
#[test]
|
||||
fn context_lines_for_delete() {
|
||||
let anchors = anchors_for(RENDER_SAMPLE);
|
||||
// Delete line 5: println!("sum = {z}");
|
||||
let applied = apply_and_convert(
|
||||
RENDER_SAMPLE,
|
||||
vec![HashlineOp::Replace {
|
||||
@@ -1006,7 +967,6 @@ mod tests {
|
||||
assert_eq!(d.old_string, " println!(\"sum = {z}\");");
|
||||
assert_eq!(d.new_string, "");
|
||||
|
||||
// Context before and after should still be populated.
|
||||
assert!(
|
||||
!d.context_before.is_empty(),
|
||||
"delete should have context_before"
|
||||
@@ -1025,7 +985,6 @@ mod tests {
|
||||
#[test]
|
||||
fn context_lines_for_multi_range_edit() {
|
||||
let anchors = anchors_for(RENDER_SAMPLE);
|
||||
// Replace line 2 (let x = 1) AND line 10 (println!("double = {w}"))
|
||||
let applied = apply_and_convert(
|
||||
RENDER_SAMPLE,
|
||||
vec![
|
||||
@@ -1044,7 +1003,6 @@ mod tests {
|
||||
|
||||
assert_eq!(applied.edits.details.len(), 2);
|
||||
|
||||
// First edit (line 2): context_before has only line 1 (fn main).
|
||||
let d0 = &applied.edits.details[0];
|
||||
assert_eq!(d0.old_string, " let x = 1;");
|
||||
assert!(
|
||||
@@ -1058,7 +1016,6 @@ mod tests {
|
||||
d0.context_after
|
||||
);
|
||||
|
||||
// Second edit (line 10): context_before has lines 7-9, context_after has line 11.
|
||||
let d1 = &applied.edits.details[1];
|
||||
assert_eq!(d1.old_string, " println!(\"double = {w}\");");
|
||||
assert!(
|
||||
@@ -1075,11 +1032,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn context_at_file_boundaries() {
|
||||
// Edit the very first and very last lines — context should not panic.
|
||||
let content = "first\nsecond\nthird\n";
|
||||
let anchors = anchors_for(content);
|
||||
|
||||
// Replace first line.
|
||||
let applied = apply_and_convert(
|
||||
content,
|
||||
vec![HashlineOp::Replace {
|
||||
@@ -1099,7 +1054,6 @@ mod tests {
|
||||
d.context_after
|
||||
);
|
||||
|
||||
// Replace last content line.
|
||||
let applied = apply_and_convert(
|
||||
content,
|
||||
vec![HashlineOp::Replace {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! Stateless range-size policy for hashline edit safety.
|
||||
//!
|
||||
//! Classifies edit ranges by size and produces tiered warnings:
|
||||
//! - Small (≤5 lines): no warning
|
||||
//! - Medium (6–20 lines): caution
|
||||
//! - Large (>20 lines): stronger caution
|
||||
//!
|
||||
//! No session state — purely a function of the requested range.
|
||||
//! Stateless range-size policy for hashline edit safety: classify an edit
|
||||
//! range by line count and emit a tiered caution warning.
|
||||
|
||||
const SMALL_MAX: usize = 5;
|
||||
const MEDIUM_MAX: usize = 20;
|
||||
@@ -29,7 +23,6 @@ impl RangeSize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a range edit and return a warning for medium or large ranges.
|
||||
pub fn range_warning(start: usize, end: usize) -> Option<String> {
|
||||
let count = end.saturating_sub(start);
|
||||
match RangeSize::classify(count) {
|
||||
|
||||
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
|
||||
/// Input for the `hashline_edit` tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct HashlineEditInput {
|
||||
/// Path to the file to edit.
|
||||
#[schemars(description = "The path of the file to edit.")]
|
||||
pub file_path: String,
|
||||
|
||||
|
||||
@@ -20,10 +20,8 @@ use super::anchor::split_lines;
|
||||
use super::config::HashlineSchemeParams;
|
||||
use super::scheme::{Anchor, AnchorScheme};
|
||||
|
||||
/// Default timeout for anchor injection (seconds).
|
||||
const DEFAULT_ANCHOR_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Get cached anchors or generate and cache them for a single invocation.
|
||||
async fn get_or_generate<'a>(
|
||||
cache: &'a mut HashMap<PathBuf, Vec<Anchor>>,
|
||||
path: &Path,
|
||||
@@ -152,7 +150,6 @@ Usage:
|
||||
- Only use 'type' or 'glob' when certain of the file type
|
||||
- Results are capped; truncated results show "at least" counts"#;
|
||||
|
||||
/// `hashline_grep` — searches with anchor-annotated results.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HashlineGrepTool;
|
||||
|
||||
@@ -237,7 +234,6 @@ impl kigi_tool_runtime::Tool for HashlineGrepTool {
|
||||
|
||||
let output_mode = input.output_mode.clone().unwrap_or(OutputMode::Content);
|
||||
|
||||
// Delegate to standard GrepTool for ripgrep execution.
|
||||
let grep = GrepTool;
|
||||
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
|
||||
let call_id = kigi_tool_protocol::ToolCallId::new_v7();
|
||||
@@ -253,7 +249,6 @@ impl kigi_tool_runtime::Tool for HashlineGrepTool {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Inject anchors only for content mode.
|
||||
if matches!(output_mode, OutputMode::Content) && result.exit_code == 0 {
|
||||
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
|
||||
let (fs, scheme) = {
|
||||
@@ -369,7 +364,6 @@ mod tests {
|
||||
|
||||
let fs = Arc::new(LocalFs);
|
||||
|
||||
// Simulate ripgrep output for "let" search.
|
||||
let rg_output = format!(
|
||||
"<workspace_result workspace_path=\"{}\">\n\
|
||||
Found 2 matching lines\n\
|
||||
@@ -450,14 +444,12 @@ mod tests {
|
||||
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
|
||||
let output = String::from_utf8_lossy(&result);
|
||||
|
||||
// Context line should use '-' separator after the anchor.
|
||||
let line_2 = output.lines().find(|l| l.starts_with('2')).unwrap();
|
||||
assert!(
|
||||
line_2.contains('-'),
|
||||
"context line should keep '-': {line_2}"
|
||||
);
|
||||
|
||||
// Match line should use ':' separator after the anchor.
|
||||
let line_3 = output.lines().find(|l| l.starts_with('3')).unwrap();
|
||||
// Count colons: line:local:context:content = 3 colons with ':'
|
||||
let colon_count = line_3.matches(':').count();
|
||||
@@ -476,7 +468,6 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let fs = Arc::new(LocalFs);
|
||||
|
||||
// File doesn't exist — anchors can't be generated.
|
||||
let rg_output = format!(
|
||||
"<workspace_result workspace_path=\"{}\">\n\
|
||||
Found 1 matching lines\n\
|
||||
@@ -490,7 +481,6 @@ mod tests {
|
||||
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
|
||||
let output = String::from_utf8_lossy(&result);
|
||||
|
||||
// Should fall through without anchors — original line preserved.
|
||||
assert!(output.contains("5:some content"));
|
||||
}
|
||||
|
||||
@@ -552,7 +542,6 @@ mod tests {
|
||||
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
|
||||
let output = String::from_utf8_lossy(&result);
|
||||
|
||||
// No numbered lines → no anchor injection. Output should be unchanged.
|
||||
assert!(output.contains("src/main.rs"));
|
||||
assert!(output.contains("src/lib.rs"));
|
||||
assert!(
|
||||
@@ -570,7 +559,6 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let fs = Arc::new(LocalFs);
|
||||
|
||||
// count output: "file:N" format.
|
||||
let rg_output = format!(
|
||||
"<workspace_result workspace_path=\"{}\">\n\
|
||||
src/main.rs:5\n\
|
||||
@@ -619,7 +607,6 @@ mod tests {
|
||||
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
|
||||
let output = String::from_utf8_lossy(&result);
|
||||
|
||||
// All 4 lines should be anchored (same file, same cache entry).
|
||||
let anchored_count = output
|
||||
.lines()
|
||||
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
|
||||
@@ -725,7 +712,6 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("big.rs"), "match\n".repeat(100)).unwrap();
|
||||
|
||||
let fs = Arc::new(LocalFs);
|
||||
// Simulate truncated output with "... [N lines truncated]" marker.
|
||||
let mut rg_lines = String::new();
|
||||
for i in 1..=10 {
|
||||
rg_lines.push_str(&format!("{i}:match\n"));
|
||||
@@ -744,11 +730,8 @@ mod tests {
|
||||
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
|
||||
let output = String::from_utf8_lossy(&result);
|
||||
|
||||
// Truncation marker should be preserved.
|
||||
assert!(output.contains("... [at least 90 lines truncated] ..."));
|
||||
// The wrapper should be intact.
|
||||
assert!(output.contains("</workspace_result>"));
|
||||
// Visible lines should be anchored.
|
||||
let anchored = output
|
||||
.lines()
|
||||
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
|
||||
|
||||
@@ -86,7 +86,6 @@ pub fn apply_mutation(lines: &mut Vec<String>, mutation: &Mutation) -> MutationR
|
||||
lines.insert(idx + i, line.clone());
|
||||
}
|
||||
|
||||
// Lines before idx: unchanged. Lines at idx and above: shifted.
|
||||
let outcomes = (0..orig_len)
|
||||
.map(|i| {
|
||||
if i < idx {
|
||||
@@ -210,8 +209,6 @@ pub fn apply_mutation(lines: &mut Vec<String>, mutation: &Mutation) -> MutationR
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an "insert lines above" mutation: insert `count` boilerplate
|
||||
/// lines before `before_idx`.
|
||||
pub fn gen_insert_above(before_idx: usize, count: usize) -> Mutation {
|
||||
let lines: Vec<String> = (0..count)
|
||||
.map(|i| format!("// inserted line {i}"))
|
||||
@@ -219,12 +216,10 @@ pub fn gen_insert_above(before_idx: usize, count: usize) -> Mutation {
|
||||
Mutation::InsertLines { before_idx, lines }
|
||||
}
|
||||
|
||||
/// Generate a "delete lines" mutation.
|
||||
pub fn gen_delete(start_idx: usize, count: usize) -> Mutation {
|
||||
Mutation::DeleteLines { start_idx, count }
|
||||
}
|
||||
|
||||
/// Generate a local token edit on a single line.
|
||||
pub fn gen_token_edit(line_idx: usize, new_content: &str) -> Mutation {
|
||||
Mutation::EditLine {
|
||||
line_idx,
|
||||
@@ -232,7 +227,6 @@ pub fn gen_token_edit(line_idx: usize, new_content: &str) -> Mutation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a formatter-style re-indentation.
|
||||
pub fn gen_reindent(line_idx: usize, new_indent: &str) -> Mutation {
|
||||
Mutation::ReindentLine {
|
||||
line_idx,
|
||||
@@ -240,7 +234,6 @@ pub fn gen_reindent(line_idx: usize, new_indent: &str) -> Mutation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a range rewrite replacing `start_idx..end_idx` with new content.
|
||||
pub fn gen_range_rewrite(start_idx: usize, end_idx: usize, new_lines: &[&str]) -> Mutation {
|
||||
Mutation::RangeRewrite {
|
||||
start_idx,
|
||||
@@ -249,7 +242,6 @@ pub fn gen_range_rewrite(start_idx: usize, end_idx: usize, new_lines: &[&str]) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a boilerplate-insertion mutation: insert repeated identical lines.
|
||||
pub fn gen_boilerplate_insert(before_idx: usize, line: &str, count: usize) -> Mutation {
|
||||
Mutation::InsertLines {
|
||||
before_idx,
|
||||
@@ -281,7 +273,6 @@ mod tests {
|
||||
assert!(lines[2].contains("inserted line 1"));
|
||||
assert_eq!(lines[3], " let x = 1;");
|
||||
assert_eq!(result.line_delta, 2);
|
||||
// Line 0 unchanged, lines 1-4 shifted by +2.
|
||||
assert_eq!(result.outcomes[0], LineOutcome::Unchanged);
|
||||
assert_eq!(result.outcomes[1], LineOutcome::Shifted { new_idx: 3 });
|
||||
assert_eq!(result.outcomes[4], LineOutcome::Shifted { new_idx: 6 });
|
||||
@@ -290,12 +281,12 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_at_end() {
|
||||
let mut lines = sample_lines();
|
||||
let m = gen_insert_above(100, 1); // past end → clamped
|
||||
// past end → clamped
|
||||
let m = gen_insert_above(100, 1);
|
||||
let result = apply_mutation(&mut lines, &m);
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[5].contains("inserted line 0"));
|
||||
assert_eq!(result.line_delta, 1);
|
||||
// All original lines unchanged (insert was at end).
|
||||
assert!(result.outcomes.iter().all(|o| *o == LineOutcome::Unchanged));
|
||||
}
|
||||
|
||||
@@ -317,9 +308,10 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_past_end_clamped() {
|
||||
let mut lines = sample_lines();
|
||||
let m = gen_delete(3, 100); // tries to delete 100 from idx 3
|
||||
let m = gen_delete(3, 100);
|
||||
let result = apply_mutation(&mut lines, &m);
|
||||
assert_eq!(lines.len(), 3); // only deleted 2 (indices 3,4)
|
||||
// only deleted 2 (indices 3,4)
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(result.line_delta, -2);
|
||||
assert_eq!(result.outcomes[3], LineOutcome::Deleted);
|
||||
assert_eq!(result.outcomes[4], LineOutcome::Deleted);
|
||||
@@ -339,7 +331,8 @@ mod tests {
|
||||
#[test]
|
||||
fn reindent_line() {
|
||||
let mut lines = sample_lines();
|
||||
let m = gen_reindent(1, " "); // double indent
|
||||
// double indent
|
||||
let m = gen_reindent(1, " ");
|
||||
let result = apply_mutation(&mut lines, &m);
|
||||
assert_eq!(lines[1], " let x = 1;");
|
||||
assert_eq!(result.line_delta, 0);
|
||||
@@ -351,7 +344,8 @@ mod tests {
|
||||
let mut lines = sample_lines();
|
||||
let m = gen_range_rewrite(1, 3, &[" let z = 42;"]);
|
||||
let result = apply_mutation(&mut lines, &m);
|
||||
assert_eq!(lines.len(), 4); // 5 - 2 removed + 1 added
|
||||
// 5 - 2 removed + 1 added
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[1], " let z = 42;");
|
||||
assert_eq!(lines[2], " println!(\"{x} {y}\");");
|
||||
assert_eq!(result.line_delta, -1);
|
||||
@@ -371,7 +365,6 @@ mod tests {
|
||||
assert_eq!(lines[2], "// boilerplate");
|
||||
assert_eq!(lines[3], "fn main() {");
|
||||
assert_eq!(result.line_delta, 3);
|
||||
// All original lines shifted by +3.
|
||||
assert_eq!(result.outcomes[0], LineOutcome::Shifted { new_idx: 3 });
|
||||
assert_eq!(result.outcomes[4], LineOutcome::Shifted { new_idx: 7 });
|
||||
}
|
||||
@@ -385,7 +378,8 @@ mod tests {
|
||||
&[" let a = 1;", " let b = 2;", " let c = 3;"],
|
||||
);
|
||||
let result = apply_mutation(&mut lines, &m);
|
||||
assert_eq!(lines.len(), 7); // 5 - 1 removed + 3 added
|
||||
// 5 - 1 removed + 3 added
|
||||
assert_eq!(lines.len(), 7);
|
||||
assert_eq!(result.line_delta, 2);
|
||||
assert_eq!(result.outcomes[1], LineOutcome::Deleted);
|
||||
assert_eq!(result.outcomes[2], LineOutcome::Shifted { new_idx: 4 });
|
||||
|
||||
@@ -43,7 +43,7 @@ pub(crate) fn format_hashline_content(
|
||||
let mut first_line: Option<usize> = None;
|
||||
|
||||
for (i, line) in all_lines.iter().enumerate().skip(skip).take(take) {
|
||||
let line_num = i + 1; // 1-based
|
||||
let line_num = i + 1;
|
||||
|
||||
if first_line.is_none() {
|
||||
first_line = Some(line_num);
|
||||
@@ -52,14 +52,11 @@ pub(crate) fn format_hashline_content(
|
||||
raw_output.push('\n');
|
||||
}
|
||||
|
||||
// Build the anchor suffix: "local" or "local:context" (without line number,
|
||||
// since we format the line number separately with right-alignment).
|
||||
let anchor_suffix = match &anchors[i].context {
|
||||
Some(ctx) => format!("{}:{ctx}", anchors[i].local),
|
||||
None => anchors[i].local.clone(),
|
||||
};
|
||||
|
||||
// Format: "LINE:LOCAL:CONTEXT→CONTENT" (or "LINE:LOCAL→CONTENT" for A)
|
||||
_ = write!(&mut output, "{line_num}:{anchor_suffix}→{line}").ok();
|
||||
raw_output.push_str(line);
|
||||
}
|
||||
@@ -228,16 +225,14 @@ impl kigi_tool_runtime::Tool for HashlineReadTool {
|
||||
format_hashline_content(&full_content, fc.offset, effective_limit, &*scheme);
|
||||
|
||||
fc.content = hashline_content;
|
||||
fc.content_concise = None; // hashline has only one format
|
||||
// hashline has only one format
|
||||
fc.content_concise = None;
|
||||
// Drop tool-layer captures: `hashline_content` keeps the
|
||||
// original URIs intact, so session-layer extraction will
|
||||
// catch them — clearing here avoids double-injection.
|
||||
fc.extracted_images.clear();
|
||||
// raw_output, offset, limit, tracking remain as set by
|
||||
// run_read_file — windowed semantics preserved.
|
||||
Ok(ReadFileOutput::FileContent(fc))
|
||||
}
|
||||
// Non-text results (images, errors) pass through unchanged.
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
@@ -263,17 +258,12 @@ mod tests {
|
||||
resources
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_hashline_content unit tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_basic_file() {
|
||||
let content = "line one\nline two\nline three\n";
|
||||
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
|
||||
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
|
||||
|
||||
// Each line should have the pattern: ANCHOR→CONTENT
|
||||
for line in output.lines() {
|
||||
assert!(line.contains(':'), "missing anchor separator: {line}");
|
||||
assert!(line.contains('→'), "missing content separator: {line}");
|
||||
@@ -286,8 +276,6 @@ mod tests {
|
||||
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
|
||||
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
|
||||
|
||||
// chunk scheme produces LINE:LOCAL:CONTEXT→CONTENT
|
||||
// Check that the first content line has two colons (line:local:context)
|
||||
let first_content_line = output.lines().next().unwrap();
|
||||
let before_arrow = first_content_line.split('→').next().unwrap();
|
||||
let colon_count = before_arrow.matches(':').count();
|
||||
@@ -303,7 +291,6 @@ mod tests {
|
||||
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
|
||||
let (output, _raw) = format_hashline_content(content, Some(2), Some(2), &*scheme);
|
||||
|
||||
// Should contain lines starting with "2:" and "3:"
|
||||
let content_lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(content_lines.len(), 2);
|
||||
assert!(content_lines[0].starts_with("2:"));
|
||||
@@ -316,7 +303,6 @@ mod tests {
|
||||
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
|
||||
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
|
||||
|
||||
// Should produce a single anchored empty line.
|
||||
assert!(output.contains("1:"), "should contain line 1");
|
||||
assert!(output.contains('→'), "should contain arrow separator");
|
||||
}
|
||||
@@ -345,10 +331,6 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HashlineReadTool integration tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tool_metadata() {
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
@@ -399,11 +381,9 @@ mod tests {
|
||||
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
// Hashline format: ANCHOR→CONTENT
|
||||
assert!(fc.content.contains('→'));
|
||||
assert!(fc.content.contains("fn main()"));
|
||||
|
||||
// Should have chunk-style anchors (two colons before →)
|
||||
let first_line = fc.content.lines().next().unwrap();
|
||||
let before_arrow = first_line.split('→').next().unwrap();
|
||||
assert!(
|
||||
@@ -411,7 +391,6 @@ mod tests {
|
||||
"expected chunk anchors, got: {before_arrow}"
|
||||
);
|
||||
|
||||
// concise should be None for hashline
|
||||
assert!(fc.content_concise.is_none());
|
||||
}
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
@@ -568,7 +547,6 @@ mod tests {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
let content_lines: Vec<&str> = fc.content.lines().collect();
|
||||
|
||||
// Exactly 2 content lines should be rendered.
|
||||
assert_eq!(
|
||||
content_lines.len(),
|
||||
2,
|
||||
@@ -577,7 +555,6 @@ mod tests {
|
||||
content_lines
|
||||
);
|
||||
|
||||
// Line numbers should be 2 and 3 (original file positions).
|
||||
assert!(
|
||||
content_lines[0].starts_with("2:"),
|
||||
"first line should start with '2:', got: {}",
|
||||
@@ -589,13 +566,11 @@ mod tests {
|
||||
content_lines[1]
|
||||
);
|
||||
|
||||
// Content should be the original lines "beta" and "gamma".
|
||||
let after_arrow_0 = content_lines[0].split('→').nth(1).unwrap();
|
||||
let after_arrow_1 = content_lines[1].split('→').nth(1).unwrap();
|
||||
assert_eq!(after_arrow_0, "beta", "line 2 content mismatch");
|
||||
assert_eq!(after_arrow_1, "gamma", "line 3 content mismatch");
|
||||
|
||||
// The stored offset/limit should reflect the original request.
|
||||
assert_eq!(fc.offset, Some(2));
|
||||
assert_eq!(fc.limit, Some(2));
|
||||
}
|
||||
@@ -629,8 +604,6 @@ mod tests {
|
||||
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
// raw_output should contain only the windowed content (beta, gamma),
|
||||
// not the full file.
|
||||
assert!(
|
||||
fc.raw_output.contains("beta"),
|
||||
"raw_output should contain 'beta'"
|
||||
@@ -656,8 +629,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn small_window_into_large_file_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Create a file with many lines (more than would fit in token budget
|
||||
// if read fully, but a small window should be fine).
|
||||
let mut content = String::new();
|
||||
for i in 0..2000 {
|
||||
content.push_str(&format!("// line {i}: some padding content here\n"));
|
||||
@@ -678,7 +649,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should succeed with FileContent, not FileTooLarge.
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
let content_lines: Vec<&str> = fc.content.lines().collect();
|
||||
@@ -715,7 +685,6 @@ mod tests {
|
||||
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
// With offset=100 on a 4-line file, no content lines should be rendered.
|
||||
let content_lines: Vec<&str> = fc.content.lines().collect();
|
||||
assert!(
|
||||
content_lines.is_empty(),
|
||||
@@ -792,7 +761,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit limit exceeding MAX_LINES_READ gets capped.
|
||||
#[tokio::test]
|
||||
async fn explicit_large_limit_capped_to_max_lines() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -21,9 +21,6 @@ use std::fmt;
|
||||
use crate::util::hash::{self, DEFAULT_HASH_LEN};
|
||||
|
||||
/// Trait for pluggable anchor generation and validation schemes.
|
||||
///
|
||||
/// Implementations generate anchors for file lines and validate anchors
|
||||
/// against current file content.
|
||||
pub trait AnchorScheme: fmt::Debug + Send + Sync {
|
||||
/// Machine-readable name for this scheme (e.g. `"content_only_v1"`).
|
||||
fn name(&self) -> &str;
|
||||
@@ -31,16 +28,11 @@ pub trait AnchorScheme: fmt::Debug + Send + Sync {
|
||||
/// Number of lowercase letters in the local line hash component.
|
||||
fn hash_len(&self) -> usize;
|
||||
|
||||
/// Generate anchors for all lines in a file.
|
||||
///
|
||||
/// `lines` is a slice of the file's lines (without trailing newlines).
|
||||
/// Returns one `Anchor` per line, in order.
|
||||
/// `lines` are the file's lines without trailing newlines. Returns one
|
||||
/// `Anchor` per line, in order.
|
||||
fn generate_anchors(&self, lines: &[&str]) -> Vec<Anchor>;
|
||||
|
||||
/// Validate a parsed anchor against current file content.
|
||||
///
|
||||
/// `anchor` is the anchor to validate. `lines` is the current file
|
||||
/// content split by line. Returns the validation result.
|
||||
fn validate(&self, anchor: &ParsedAnchor, lines: &[&str]) -> ValidationResult;
|
||||
|
||||
/// Estimated number of lines read to validate a single anchor at
|
||||
@@ -54,10 +46,6 @@ pub trait AnchorScheme: fmt::Debug + Send + Sync {
|
||||
|
||||
/// Search for a shifted anchor within a bounded window around the
|
||||
/// original line number.
|
||||
///
|
||||
/// Returns `ShiftResult::Found` if exactly one nearby line validates
|
||||
/// under this scheme, `ShiftResult::Ambiguous` if multiple candidates
|
||||
/// match, and `ShiftResult::NotFound` if none match.
|
||||
fn find_shifted(
|
||||
&self,
|
||||
anchor: &ParsedAnchor,
|
||||
@@ -78,8 +66,6 @@ pub struct Anchor {
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Render this anchor as a string suitable for output.
|
||||
///
|
||||
/// Format: `"LINE:LOCAL"` or `"LINE:LOCAL:CONTEXT"`.
|
||||
pub fn render(&self) -> String {
|
||||
match &self.context {
|
||||
@@ -107,8 +93,6 @@ pub struct ParsedAnchor {
|
||||
}
|
||||
|
||||
impl ParsedAnchor {
|
||||
/// Parse an anchor string into its components.
|
||||
///
|
||||
/// Accepted formats:
|
||||
/// - `"22:abc"` → line=22, local="abc", context=None
|
||||
/// - `"22:abc:rst"` → line=22, local="abc", context=Some("rst")
|
||||
@@ -129,13 +113,11 @@ impl ParsedAnchor {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Validate local hash: must be all lowercase ASCII letters.
|
||||
if !local.bytes().all(|b| b.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let context = parts.next().map(|s| s.to_owned());
|
||||
// Validate context hash if present: must be non-empty lowercase ASCII letters.
|
||||
if let Some(ref ctx) = context
|
||||
&& (ctx.is_empty() || !ctx.bytes().all(|b| b.is_ascii_lowercase()))
|
||||
{
|
||||
@@ -149,7 +131,6 @@ impl ParsedAnchor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Render back to string form.
|
||||
pub fn render(&self) -> String {
|
||||
match &self.context {
|
||||
Some(ctx) => format!("{}:{}:{}", self.line, self.local, ctx),
|
||||
@@ -321,11 +302,9 @@ impl ChunkFingerprint {
|
||||
let chunk_start = (line_idx / self.chunk_size) * self.chunk_size;
|
||||
let chunk_end = (chunk_start + self.chunk_size).min(lines.len());
|
||||
|
||||
// Hash all normalized lines in the chunk together.
|
||||
let mut combined: u32 = hash::fnv1a_32(b"chunk");
|
||||
for line in &lines[chunk_start..chunk_end] {
|
||||
let lh = hash::line_hash(line);
|
||||
// Mix each line hash into the combined hash.
|
||||
combined ^= lh;
|
||||
combined = combined.wrapping_mul(16_777_619);
|
||||
}
|
||||
@@ -390,7 +369,6 @@ impl AnchorScheme for ChunkFingerprint {
|
||||
return ValidationResult::OutOfRange;
|
||||
}
|
||||
|
||||
// Validate local line hash.
|
||||
let expected_local = hash::encode_hash(hash::line_hash(lines[idx]), self.hash_len);
|
||||
if anchor.local != expected_local {
|
||||
return ValidationResult::Stale;
|
||||
@@ -528,7 +506,6 @@ impl AnchorScheme for CheckpointChain {
|
||||
return ValidationResult::OutOfRange;
|
||||
}
|
||||
|
||||
// Validate local line hash.
|
||||
let expected_local = hash::encode_hash(hash::line_hash(lines[idx]), self.hash_len);
|
||||
if anchor.local != expected_local {
|
||||
return ValidationResult::Stale;
|
||||
@@ -587,7 +564,6 @@ fn find_shifted_generic(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cheap check: does the local line hash match?
|
||||
let local = hash::encode_hash(hash::line_hash(lines[idx]), hash_len);
|
||||
if local != anchor.local {
|
||||
continue;
|
||||
@@ -623,9 +599,7 @@ fn find_shifted_generic(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test fixture
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn sample_lines() -> Vec<&'static str> {
|
||||
vec![
|
||||
@@ -637,9 +611,7 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ParsedAnchor tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_anchor_two_parts() {
|
||||
@@ -671,16 +643,19 @@ mod tests {
|
||||
assert!(ParsedAnchor::parse("abc").is_none());
|
||||
assert!(ParsedAnchor::parse(":abc").is_none());
|
||||
assert!(ParsedAnchor::parse("22:").is_none());
|
||||
assert!(ParsedAnchor::parse("0:abc").is_none()); // line 0 invalid
|
||||
assert!(ParsedAnchor::parse("22:ABC").is_none()); // uppercase
|
||||
assert!(ParsedAnchor::parse("22:abc:").is_none()); // empty context
|
||||
assert!(ParsedAnchor::parse("22:abc:XYZ").is_none()); // uppercase context
|
||||
assert!(ParsedAnchor::parse("abc:def").is_none()); // non-numeric line
|
||||
// line 0 invalid
|
||||
assert!(ParsedAnchor::parse("0:abc").is_none());
|
||||
// uppercase
|
||||
assert!(ParsedAnchor::parse("22:ABC").is_none());
|
||||
// empty context
|
||||
assert!(ParsedAnchor::parse("22:abc:").is_none());
|
||||
// uppercase context
|
||||
assert!(ParsedAnchor::parse("22:abc:XYZ").is_none());
|
||||
// non-numeric line
|
||||
assert!(ParsedAnchor::parse("abc:def").is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Anchor::render tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn anchor_render_without_context() {
|
||||
@@ -703,9 +678,7 @@ mod tests {
|
||||
assert_eq!(a.render(), "22:abc:rst");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Candidate A — ContentOnly
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn content_only_generates_correct_count() {
|
||||
@@ -814,9 +787,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Candidate B — ChunkFingerprint
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chunk_generates_context() {
|
||||
@@ -830,7 +801,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn chunk_same_chunk_same_context() {
|
||||
let lines = sample_lines(); // 5 lines, all in chunk 0 (size 16)
|
||||
// 5 lines, all in chunk 0 (size 16)
|
||||
let lines = sample_lines();
|
||||
let scheme = ChunkFingerprint::new();
|
||||
let anchors = scheme.generate_anchors(&lines);
|
||||
let ctx0 = anchors[0].context.as_ref().unwrap();
|
||||
@@ -899,9 +871,7 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Candidate C — CheckpointChain
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn checkpoint_generates_context() {
|
||||
@@ -957,9 +927,7 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shared: find_shifted recovery tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_shifted_after_insert_above() {
|
||||
@@ -968,13 +936,13 @@ mod tests {
|
||||
let scheme = ContentOnly::new();
|
||||
let anchors = scheme.generate_anchors(&lines);
|
||||
|
||||
// Insert a new line at position 0 → all lines shift down by 1.
|
||||
let mut shifted = vec!["// new line"];
|
||||
shifted.extend_from_slice(&lines);
|
||||
|
||||
// Anchor for original line 3 ("export function App() {") is now at line 4.
|
||||
let parsed = ParsedAnchor {
|
||||
line: anchors[2].line, // line 3
|
||||
// line 3
|
||||
line: anchors[2].line,
|
||||
local: anchors[2].local.clone(),
|
||||
context: None,
|
||||
};
|
||||
@@ -1012,7 +980,8 @@ mod tests {
|
||||
let anchors = scheme.generate_anchors(&lines);
|
||||
let parsed = ParsedAnchor {
|
||||
line: 5,
|
||||
local: anchors[0].local.clone(), // same hash for all lines
|
||||
// same hash for all lines
|
||||
local: anchors[0].local.clone(),
|
||||
context: None,
|
||||
};
|
||||
|
||||
@@ -1024,9 +993,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Finding 1: B/C reject missing context
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chunk_rejects_anchor_without_context() {
|
||||
@@ -1038,7 +1005,8 @@ mod tests {
|
||||
let truncated = ParsedAnchor {
|
||||
line: anchors[0].line,
|
||||
local: anchors[0].local.clone(),
|
||||
context: None, // intentionally missing
|
||||
// intentionally missing
|
||||
context: None,
|
||||
};
|
||||
assert_eq!(scheme.validate(&truncated, &lines), ValidationResult::Stale);
|
||||
}
|
||||
@@ -1052,14 +1020,13 @@ mod tests {
|
||||
let truncated = ParsedAnchor {
|
||||
line: anchors[0].line,
|
||||
local: anchors[0].local.clone(),
|
||||
context: None, // intentionally missing
|
||||
// intentionally missing
|
||||
context: None,
|
||||
};
|
||||
assert_eq!(scheme.validate(&truncated, &lines), ValidationResult::Stale);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Finding 4: B/C shifted recovery after insertion/deletion
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chunk_find_shifted_after_insert_above() {
|
||||
@@ -1075,7 +1042,8 @@ mod tests {
|
||||
// Anchor for original line 3 with context — shifted recovery should
|
||||
// find it at line 4 (same local + recomputed context at new position).
|
||||
let parsed = ParsedAnchor {
|
||||
line: anchors[2].line, // line 3
|
||||
// line 3
|
||||
line: anchors[2].line,
|
||||
local: anchors[2].local.clone(),
|
||||
context: anchors[2].context.clone(),
|
||||
};
|
||||
@@ -1118,9 +1086,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Finding 4: B/C ambiguity in repetitive files
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chunk_ambiguity_with_repeated_lines() {
|
||||
@@ -1167,9 +1133,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Finding 3: Invalid constructor parameters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn custom_hash_len_2() {
|
||||
@@ -1217,9 +1181,7 @@ mod tests {
|
||||
CheckpointChain::with_params(3, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scheme names
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scheme_names() {
|
||||
|
||||
@@ -49,7 +49,7 @@ impl std::fmt::Debug for LspClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Startup helpers (called by LspClient::start) ────────────────────────
|
||||
// Startup helpers, called by LspClient::start.
|
||||
|
||||
type LspMainLoopAndServer = (LspMainLoop, async_lsp::ServerSocket);
|
||||
|
||||
@@ -126,7 +126,8 @@ fn build_initialize_params(config: &LspServerConfig, workspace_root: &Path) -> I
|
||||
}]
|
||||
});
|
||||
|
||||
#[allow(deprecated)] // root_uri still needed for older servers
|
||||
// root_uri still needed for older servers
|
||||
#[allow(deprecated)]
|
||||
InitializeParams {
|
||||
root_uri: Url::from_file_path(effective_root).ok(),
|
||||
workspace_folders,
|
||||
@@ -172,8 +173,6 @@ fn abort_transport(handle: &tokio::task::JoinHandle<()>, child: &mut Option<std:
|
||||
}
|
||||
}
|
||||
|
||||
// ── LspClient ───────────────────────────────────────────────────────────
|
||||
|
||||
impl LspClient {
|
||||
pub async fn start(
|
||||
server_name: String,
|
||||
|
||||
@@ -33,7 +33,6 @@ pub fn load_servers_with_plugins_sourced(
|
||||
let user_path = crate::util::kigi_home::kigi_home().join("lsp.json");
|
||||
let project_path = cwd.join(".kigi").join("lsp.json");
|
||||
|
||||
// User-level servers
|
||||
let mut servers: BTreeMap<String, (LspServerConfig, ConfigSource)> = load_file(&user_path)
|
||||
.into_iter()
|
||||
.map(|(name, cfg)| {
|
||||
@@ -49,7 +48,6 @@ pub fn load_servers_with_plugins_sourced(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Project-level overrides
|
||||
for (name, cfg) in load_file(&project_path) {
|
||||
servers.insert(
|
||||
name,
|
||||
@@ -62,7 +60,6 @@ pub fn load_servers_with_plugins_sourced(
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin file-based configs
|
||||
for (i, lsp_path) in plugin_lsp_paths.iter().enumerate() {
|
||||
let pname = plugin_names.get(i).copied().unwrap_or("unknown");
|
||||
for (name, cfg) in load_file(lsp_path) {
|
||||
@@ -78,7 +75,6 @@ pub fn load_servers_with_plugins_sourced(
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin inline configs
|
||||
for (i, inline) in plugin_inline_lsp.iter().enumerate() {
|
||||
let pname = inline_plugin_names.get(i).copied().unwrap_or("unknown");
|
||||
match serde_json::from_value::<BTreeMap<String, LspServerConfig>>((*inline).clone()) {
|
||||
|
||||
@@ -24,8 +24,6 @@ use super::format::{
|
||||
use super::manager::LspManager;
|
||||
use super::{LspError, file_uri, text_document_position};
|
||||
|
||||
// ── Public adapter ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum StartupState {
|
||||
NotStarted,
|
||||
@@ -199,7 +197,6 @@ impl super::LspBackend for LspBackendAdapter {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Lock dropped — dispatch on cloned socket(s).
|
||||
dispatch_on_sockets(input, sockets).await
|
||||
}
|
||||
|
||||
@@ -255,7 +252,6 @@ impl super::LspBackend for LspBackendAdapter {
|
||||
let notified = notify.notified();
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_millis(1000), notified).await;
|
||||
|
||||
// Collect diagnostics from all clients for the requested paths.
|
||||
let mgr = self.lsp_manager.lock().await;
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
@@ -303,8 +299,6 @@ impl super::LspBackend for LspBackendAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal types ──────────────────────────────────────────────────────
|
||||
|
||||
enum DispatchSockets {
|
||||
One(async_lsp::ServerSocket),
|
||||
All(Vec<async_lsp::ServerSocket>),
|
||||
@@ -348,9 +342,7 @@ where
|
||||
|
||||
/// Distinguishes validation errors (missing params) from LSP protocol errors.
|
||||
enum DispatchError {
|
||||
/// Missing or invalid input parameters.
|
||||
Validation(String),
|
||||
/// LSP request failed or timed out.
|
||||
Lsp(LspError),
|
||||
}
|
||||
|
||||
@@ -366,8 +358,6 @@ impl From<LspError> for DispatchError {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn dispatch_on_sockets(input: &LspToolInput, sockets: DispatchSockets) -> LspToolResult {
|
||||
use super::LspOperation;
|
||||
|
||||
@@ -394,8 +384,6 @@ async fn dispatch_on_sockets(input: &LspToolInput, sockets: DispatchSockets) ->
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-operation helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Handles both GoToDefinition and GoToImplementation (same params/response shape).
|
||||
async fn dispatch_goto(
|
||||
input: &LspToolInput,
|
||||
@@ -528,8 +516,6 @@ async fn dispatch_workspace_symbols(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response converters ─────────────────────────────────────────────────
|
||||
|
||||
fn parse_goto_response(response: Option<GotoDefinitionResponse>) -> Vec<Location> {
|
||||
match response {
|
||||
Some(GotoDefinitionResponse::Scalar(l)) => vec![l],
|
||||
|
||||
@@ -17,7 +17,8 @@ pub fn flatten_document_symbols(
|
||||
out: &mut Vec<SymbolInformation>,
|
||||
) {
|
||||
for sym in symbols {
|
||||
#[allow(deprecated)] // container_name is deprecated but still the LSP way
|
||||
// container_name is deprecated but still the LSP way
|
||||
#[allow(deprecated)]
|
||||
out.push(SymbolInformation {
|
||||
name: sym.name.clone(),
|
||||
kind: sym.kind,
|
||||
|
||||
@@ -254,7 +254,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure data collection — reads from clients and pending state without mutation.
|
||||
fn collect_pending_diagnostics(&self) -> CollectedDiagnostics {
|
||||
let mut result = CollectedDiagnostics::default();
|
||||
|
||||
@@ -274,7 +273,8 @@ impl LspManager {
|
||||
continue;
|
||||
};
|
||||
server_had_diagnostics = true;
|
||||
let display_path = uri.strip_prefix("file://").unwrap_or(uri); // Unix-only
|
||||
// Unix-only
|
||||
let display_path = uri.strip_prefix("file://").unwrap_or(uri);
|
||||
let mut has_header = false;
|
||||
|
||||
for d in diags {
|
||||
@@ -308,10 +308,8 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Auto-open file if needed, return cloned socket for lock-free dispatch.
|
||||
/// Single resolve — no double lookup.
|
||||
pub async fn socket_for_file(&mut self, path: &Path) -> Option<async_lsp::ServerSocket> {
|
||||
let (server_name, lang_id) = super::config::resolve_server(&self.servers, path)?;
|
||||
// Auto-open if not yet tracked.
|
||||
let needs_open = self
|
||||
.clients
|
||||
.get(&server_name)
|
||||
|
||||
@@ -17,8 +17,6 @@ pub use types::{
|
||||
LspOperation, LspToolInput, LspToolResult,
|
||||
};
|
||||
|
||||
// ── Shared types used across submodules ─────────────────────────────────
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,7 +10,6 @@ use super::config::LspServerConfig;
|
||||
use super::manager::LspManager;
|
||||
use super::{DiagnosticsNotify, file_uri};
|
||||
|
||||
/// Waits for the current lifecycle to exit.
|
||||
async fn wait_for_crashed_lifecycle(
|
||||
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
|
||||
server_name: &str,
|
||||
@@ -27,7 +26,6 @@ async fn wait_for_crashed_lifecycle(
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays tracked documents and returns their URIs.
|
||||
fn replay_tracked_documents(
|
||||
restarted_client: &mut LspClient,
|
||||
tracked_docs: &[(String, String)],
|
||||
@@ -46,7 +44,6 @@ fn replay_tracked_documents(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes the crashed client if it is still current and returns restart state.
|
||||
async fn take_crashed_client_if_current(
|
||||
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
|
||||
server_name: &str,
|
||||
@@ -82,7 +79,6 @@ async fn take_crashed_client_if_current(
|
||||
))
|
||||
}
|
||||
|
||||
/// Removes the crashed client if it is still current.
|
||||
async fn discard_crashed_client_if_current(
|
||||
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
|
||||
server_name: &str,
|
||||
@@ -99,7 +95,6 @@ async fn discard_crashed_client_if_current(
|
||||
true
|
||||
}
|
||||
|
||||
/// Installs a restarted client unless shutdown has begun.
|
||||
async fn install_restarted_client(
|
||||
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
|
||||
server_name: &str,
|
||||
|
||||
@@ -395,7 +395,6 @@ async fn poll_diagnostics(client: &LspClient, path: &Path, expected: usize) -> V
|
||||
client.get_diagnostics(path)
|
||||
}
|
||||
|
||||
/// Creates a single-server LspManager with the mock TS server, already initialized.
|
||||
async fn single_server_manager(script_path: &Path, workspace: &tempfile::TempDir) -> LspManager {
|
||||
let mut servers = BTreeMap::new();
|
||||
servers.insert("mock-ts".to_string(), mock_server_config(script_path));
|
||||
@@ -481,9 +480,9 @@ async fn e2e_lsp_manager_full_lifecycle() {
|
||||
|
||||
let mut mgr = single_server_manager(&script_path, &workspace).await;
|
||||
assert!(mgr.is_initialized());
|
||||
mgr.ensure_initialized().await; // idempotent
|
||||
// idempotent
|
||||
mgr.ensure_initialized().await;
|
||||
|
||||
// Fire-and-forget notification.
|
||||
let test_file = workspace.path().join("app.ts");
|
||||
let content = "let y = 2;\n";
|
||||
std::fs::write(&test_file, content).unwrap();
|
||||
@@ -528,7 +527,6 @@ async fn e2e_did_change_updates_diagnostics() {
|
||||
let test_file = workspace.path().join("test.ts");
|
||||
std::fs::write(&test_file, "const x = 1;\n").unwrap();
|
||||
|
||||
// First open.
|
||||
client.notify_file_change(&test_file, "const x = 1;\n", "typescript");
|
||||
let diags = poll_diagnostics(&client, &test_file, 2).await;
|
||||
assert_eq!(diags.len(), 2);
|
||||
@@ -609,7 +607,6 @@ async fn e2e_multi_server_routing() {
|
||||
// Bad server skipped, 2 good ones remain.
|
||||
assert_eq!(mgr.clients.len(), 2);
|
||||
|
||||
// .ts and .py route to their respective servers.
|
||||
let ts_file = workspace.path().join("app.ts");
|
||||
let ts_content = "let x = 1;\n";
|
||||
std::fs::write(&ts_file, ts_content).unwrap();
|
||||
@@ -620,7 +617,6 @@ async fn e2e_multi_server_routing() {
|
||||
std::fs::write(&py_file, py_content).unwrap();
|
||||
mgr.notify_file_changed(&py_file, py_content);
|
||||
|
||||
// .go has no configured server — doesn't add to pending.
|
||||
let go_file = workspace.path().join("main.go");
|
||||
std::fs::write(&go_file, "package main\n").unwrap();
|
||||
let pending_before = mgr.pending_count();
|
||||
@@ -649,7 +645,8 @@ async fn e2e_multi_server_routing() {
|
||||
summary.text
|
||||
);
|
||||
assert_eq!(summary.file_count, 2);
|
||||
assert_eq!(summary.diagnostic_count, 4); // 2 per file (error + warning)
|
||||
// 2 per file (error + warning)
|
||||
assert_eq!(summary.diagnostic_count, 4);
|
||||
|
||||
mgr.lock().await.shutdown().await;
|
||||
}
|
||||
@@ -758,13 +755,11 @@ async fn e2e_session_diagnostics_injection_flow() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut mgr = single_server_manager(&script_path, &workspace).await;
|
||||
|
||||
// Step 1: tool edits file -> fire-and-forget notify (returns immediately).
|
||||
let edited_file = workspace.path().join("component.ts");
|
||||
let content = "const x: number = 'wrong_type';\n";
|
||||
std::fs::write(&edited_file, content).unwrap();
|
||||
mgr.notify_file_changed(&edited_file, content);
|
||||
|
||||
// Step 2: (simulated) other tools run... time passes... LSP server responds.
|
||||
wait_for_server(&mgr, &edited_file, 2000).await;
|
||||
|
||||
let mgr = tokio::sync::Mutex::new(mgr);
|
||||
@@ -786,12 +781,10 @@ async fn e2e_session_diagnostics_injection_flow() {
|
||||
assert_eq!(summary.file_count, 1);
|
||||
assert_eq!(summary.diagnostic_count, 2);
|
||||
|
||||
// Step 4: the injected user message the model sees.
|
||||
let injected = format!("<system-reminder>\n{}\n</system-reminder>", summary.text);
|
||||
assert!(injected.contains("mock error: undeclared variable"));
|
||||
assert!(injected.contains("mock warning: unused import"));
|
||||
|
||||
// Step 5: .py has no server — notify is a no-op.
|
||||
{
|
||||
let mut mgr = mgr.lock().await;
|
||||
let py_file = workspace.path().join("script.py");
|
||||
@@ -817,7 +810,6 @@ async fn e2e_session_tool_dispatch_flow() {
|
||||
std::fs::write(&ts_file, content).unwrap();
|
||||
mgr.notify_file_changed(&ts_file, content);
|
||||
|
||||
// goToDefinition
|
||||
let result = mgr
|
||||
.dispatch_tool_typed(&LspToolInput {
|
||||
operation: LspOperation::GoToDefinition,
|
||||
@@ -834,7 +826,6 @@ async fn e2e_session_tool_dispatch_flow() {
|
||||
result.text
|
||||
);
|
||||
|
||||
// findReferences
|
||||
let result = mgr
|
||||
.dispatch_tool_typed(&LspToolInput {
|
||||
operation: LspOperation::FindReferences,
|
||||
@@ -848,7 +839,6 @@ async fn e2e_session_tool_dispatch_flow() {
|
||||
assert!(result.text.contains(":6:1"), "line 6: {}", result.text);
|
||||
assert!(result.text.contains(":16:4"), "line 16: {}", result.text);
|
||||
|
||||
// missing server -> error
|
||||
let rs_file = workspace.path().join("lib.rs");
|
||||
std::fs::write(&rs_file, "fn main() {}\n").unwrap();
|
||||
let result = mgr
|
||||
@@ -867,7 +857,6 @@ async fn e2e_session_tool_dispatch_flow() {
|
||||
result.text
|
||||
);
|
||||
|
||||
// missing file_path for position-based operation -> error
|
||||
let result = mgr
|
||||
.dispatch_tool_typed(&LspToolInput {
|
||||
operation: LspOperation::GoToDefinition,
|
||||
@@ -883,18 +872,15 @@ async fn e2e_session_tool_dispatch_flow() {
|
||||
mgr.shutdown().await;
|
||||
}
|
||||
|
||||
/// Verifies the tools_enabled gating logic.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn e2e_tools_enabled_gating() {
|
||||
let (_dir, script_path) = write_mock_server();
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
||||
// tools_enabled=false (default) — tools should NOT be advertised.
|
||||
let mut mgr = single_server_manager(&script_path, &workspace).await;
|
||||
assert!(!mgr.tools_enabled(), "tools disabled by default");
|
||||
mgr.shutdown().await;
|
||||
|
||||
// tools_enabled=true — Arc<dyn LspBackend> would be injected into ToolBridge Resources.
|
||||
let mut servers = BTreeMap::new();
|
||||
servers.insert("mock-ts".to_string(), mock_server_config(&script_path));
|
||||
let mut mgr = LspManager {
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::collections::BTreeMap;
|
||||
use super::config::LspServerConfig;
|
||||
use super::manager::DiagnosticsSummary;
|
||||
|
||||
/// LSP configuration passed from shell. Same pattern as `WebSearchConfig`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum LspConfig {
|
||||
#[default]
|
||||
@@ -25,9 +24,6 @@ pub struct LspToolResult {
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// Trait object interface for LSP operations.
|
||||
///
|
||||
/// Implemented by `LspBackendAdapter` which wraps `LspManager`.
|
||||
#[async_trait::async_trait]
|
||||
pub trait LspBackend: Send + Sync + 'static {
|
||||
fn ensure_started_background(&self);
|
||||
@@ -50,7 +46,6 @@ pub trait LspBackend: Send + Sync + 'static {
|
||||
async fn read_diagnostics(&self, paths: &[std::path::PathBuf]) -> Vec<FileDiagnosticEntry>;
|
||||
}
|
||||
|
||||
/// A single diagnostic entry returned by `LspBackend::read_diagnostics`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticEntry {
|
||||
pub severity: DiagnosticSeverityLevel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `memory_get` tool — new architecture (`Tool` trait).
|
||||
//! `memory_get` tool.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,17 +7,15 @@ use crate::types::memory_backend::MemoryBackend;
|
||||
use crate::types::output::ToolOutput;
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
/// Format content with line numbers: `{line_num}→{line}`.
|
||||
///
|
||||
/// Extracted as a free function so it can be unit-tested independently of
|
||||
/// the async tool infrastructure. `first_line_num` is the 1-based number
|
||||
/// for the first line of `content` (accounts for `from` offset).
|
||||
/// Format content with line numbers: `{line_num}→{line}`. `first_line_num`
|
||||
/// is the 1-based number of the first line of `content` (accounts for the
|
||||
/// `from` offset).
|
||||
///
|
||||
/// Uses `split('\n')` rather than `lines()` so that content ending with a
|
||||
/// newline (`"a\n"`) emits a trailing blank numbered line, matching the
|
||||
/// behavior of the standard `read_file` tool. `lines()` would silently drop
|
||||
/// that trailing element, causing off-by-one line references for files
|
||||
/// (virtually all Markdown memory files) that end with a newline.
|
||||
/// standard `read_file` tool. `lines()` would silently drop that trailing
|
||||
/// element, causing off-by-one line references for files (virtually all
|
||||
/// Markdown memory files) that end with a newline.
|
||||
pub(crate) fn format_with_line_numbers(content: &str, first_line_num: usize) -> String {
|
||||
if content.is_empty() {
|
||||
return String::new();
|
||||
@@ -124,51 +122,42 @@ impl kigi_tool_runtime::Tool for MemoryGetImpl {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// format_with_line_numbers produces 1-based unpadded output.
|
||||
#[test]
|
||||
fn test_format_basic_line_numbers() {
|
||||
let out = format_with_line_numbers("alpha\nbeta\ngamma", 1);
|
||||
assert_eq!(out, "1→alpha\n2→beta\n3→gamma");
|
||||
}
|
||||
|
||||
/// The `from` offset shifts the first line number so numbers reflect the
|
||||
/// actual position in the source file, not the slice position.
|
||||
#[test]
|
||||
fn test_format_offset_adjusts_line_numbers() {
|
||||
// Simulates memory_get called with from=4 (0-based) — first displayed
|
||||
// line should be labelled "5" (1-based).
|
||||
// from=4 (0-based) makes the first displayed line number 5 (1-based).
|
||||
let out = format_with_line_numbers("line five\nline six", 5);
|
||||
assert!(out.starts_with("5→line five"), "got: {out}");
|
||||
assert!(out.ends_with("6→line six"), "got: {out}");
|
||||
}
|
||||
|
||||
/// Empty content produces empty output (no panic).
|
||||
#[test]
|
||||
fn test_format_empty_content() {
|
||||
let out = format_with_line_numbers("", 1);
|
||||
assert!(out.is_empty(), "empty input must produce empty output");
|
||||
}
|
||||
|
||||
/// Single-line content produces one numbered line.
|
||||
#[test]
|
||||
fn test_format_single_line() {
|
||||
let out = format_with_line_numbers("only line", 1);
|
||||
assert_eq!(out, "1→only line");
|
||||
}
|
||||
|
||||
/// Wide line numbers (>= 7 digits) are not truncated.
|
||||
#[test]
|
||||
fn test_format_large_line_numbers() {
|
||||
let out = format_with_line_numbers("x", 1_000_000);
|
||||
assert!(out.starts_with("1000000→"), "got: {out}");
|
||||
}
|
||||
|
||||
/// Content ending with `\n` emits a trailing blank numbered line.
|
||||
///
|
||||
/// Regression test for the `lines()` vs `split('\n')` difference.
|
||||
/// Virtually all Markdown memory files end with a trailing newline, so
|
||||
/// without this fix `memory_get` line numbers are off-by-one relative to
|
||||
/// `read_file` for any file that ends with a newline.
|
||||
/// Regression test for the `lines()` vs `split('\n')` difference:
|
||||
/// virtually all Markdown memory files end with a trailing newline, so
|
||||
/// with `lines()` `memory_get` line numbers would be off-by-one relative
|
||||
/// to `read_file`.
|
||||
#[test]
|
||||
fn test_format_trailing_newline_emits_blank_line() {
|
||||
let out = format_with_line_numbers("alpha\n", 1);
|
||||
@@ -178,14 +167,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Two trailing newlines produce two extra blank lines.
|
||||
#[test]
|
||||
fn test_format_double_trailing_newline() {
|
||||
let out = format_with_line_numbers("a\n\n", 1);
|
||||
assert_eq!(out, "1→a\n2→\n3→");
|
||||
}
|
||||
|
||||
/// Content without a trailing newline does NOT produce a spurious blank line.
|
||||
#[test]
|
||||
fn test_format_no_trailing_newline_no_blank_line() {
|
||||
let out = format_with_line_numbers("alpha", 1);
|
||||
|
||||
@@ -10,13 +10,11 @@ pub mod types;
|
||||
pub use get_tool::MemoryGetImpl;
|
||||
pub use search_tool::MemorySearchImpl;
|
||||
|
||||
/// Registered name of the `memory_search` tool.
|
||||
///
|
||||
/// Single source of truth shared between the tool definition and any
|
||||
/// gating callers (e.g. shell-side slash-command availability checks).
|
||||
/// Registered name of the `memory_search` tool. Single source of truth
|
||||
/// shared between the tool definition and gating callers (e.g. shell-side
|
||||
/// slash-command availability checks).
|
||||
pub const MEMORY_SEARCH_TOOL_NAME: &str = "memory_search";
|
||||
|
||||
/// Registered name of the `memory_get` tool.
|
||||
pub const MEMORY_GET_TOOL_NAME: &str = "memory_get";
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `memory_search` tool — new architecture (`Tool` trait).
|
||||
//! `memory_search` tool.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user