Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/125.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bash, task, and workflow tools now show live progress in the UI while they run.
104 changes: 103 additions & 1 deletion n00n-lua/tests/real_plugins_restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
//! A broken restore silently falls back to raw LLM output, so we assert
//! things only the real views produce (gutters, command headers, truncation).

use std::sync::Arc;
use std::{
sync::Arc,
thread::JoinHandle,
time::{Duration, Instant},
};

use n00n_agent::AgentEvent;
use n00n_agent::CancelTrigger;
use n00n_agent::tools::ToolRegistry;
use n00n_config::ToolOutputLines;
use n00n_lua::PluginHost;
Expand All @@ -22,6 +27,7 @@ const BASH_SRC: &str = include_str!("../../plugins/bash/init.lua");
const BATCH_SRC: &str = include_str!("../../plugins/batch/init.lua");
const CODEGRAPH_SRC: &str = include_str!("../../plugins/codegraph/init.lua");
const GREP_SRC: &str = include_str!("../../plugins/grep/init.lua");
const WORKFLOW_SRC: &str = include_str!("../../plugins/workflow/init.lua");

/// Only the real `ToolView` emits this when collapsed.
const EXPAND_HINT: &str = "click to expand";
Expand All @@ -35,6 +41,37 @@ const BATCH_INPUT_GREP_BASH: &str = r#"{ "tool_calls": [
{ "tool": "grep", "parameters": { "pattern": "fn" } },
{ "tool": "bash", "parameters": { "command": "echo hello-from-bash" } }
]}"#;
const WORKFLOW_TOOL: &str = "workflow";
const LIVE_PREVIEW_ID: &str = "live-preview";
const LIVE_PREVIEW_EVENT_SEQUENCE: u64 = 0;
const LIVE_PREVIEW_RECEIVE_TIMEOUT: Duration = Duration::from_secs(5);
const LIVE_PREVIEW_TIMEOUT_MSG: &str = "plugin did not publish a live preview";

/// Cancels and joins the spawned tool on every exit path, including panics.
struct LivePreviewGuard<T> {
cancel: Option<CancelTrigger>,
execution: Option<JoinHandle<T>>,
}

impl<T> LivePreviewGuard<T> {
fn new(cancel: CancelTrigger, execution: JoinHandle<T>) -> Self {
Self {
cancel: Some(cancel),
execution: Some(execution),
}
}
}

impl<T> Drop for LivePreviewGuard<T> {
fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() {
cancel.cancel();
}
if let Some(execution) = self.execution.take() {
let _ = execution.join();
}
}
}

fn load_host() -> PluginHost {
let reg = Arc::new(ToolRegistry::new());
Expand All @@ -47,6 +84,71 @@ fn load_host() -> PluginHost {
host
}

fn assert_publishes_live_buf(tool: &str, source: &str, input: Value, expected: &str) {
let reg = Arc::new(ToolRegistry::new());
let host = PluginHost::new(Arc::clone(&reg)).unwrap();
if tool == WORKFLOW_TOOL {
host.load_source(
"disable_workflow_state",
"n00n.env.state_dir = function() return nil end",
)
.unwrap();
}
host.load_source("live_preview", source).unwrap();

let (tx, rx) = flume::unbounded();
let event_tx = n00n_agent::EventSender::new(tx, LIVE_PREVIEW_EVENT_SEQUENCE);
let mut ctx = n00n_agent::tools::test_support::stub_ctx_with(
&n00n_agent::AgentMode::Build,
Some(&event_tx),
Some(LIVE_PREVIEW_ID),
);
ctx.registry = Arc::clone(&reg);
let (cancel, token) = n00n_agent::CancelToken::new();
ctx.cancel = token;
let inv = reg.get(tool).unwrap().tool.parse(&input).unwrap();
let execution = std::thread::spawn(move || smol::block_on(inv.execute(&ctx)));
let _guard = LivePreviewGuard::new(cancel, execution);

let deadline = Instant::now() + LIVE_PREVIEW_RECEIVE_TIMEOUT;
let body = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "{LIVE_PREVIEW_TIMEOUT_MSG}");
let env = rx
.recv_timeout(remaining)
.unwrap_or_else(|_| panic!("{LIVE_PREVIEW_TIMEOUT_MSG}"));
if let AgentEvent::LiveToolBuf { id, body } = env.event
&& id == LIVE_PREVIEW_ID
{
break body;
}
};
assert!(
body.read()
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.text.contains(expected))
);
}

#[test_case::test_case(
"bash",
BASH_SRC,
json!({ "command": "printf live-preview" }),
"live-preview";
"bash"
)]
#[test_case::test_case(
WORKFLOW_TOOL,
WORKFLOW_SRC,
json!({ "script": "meta({ name = 'preview' }) return 'done'" }),
"workflow";
"workflow"
)]
fn running_plugin_publishes_live_preview(tool: &str, source: &str, input: Value, expected: &str) {
assert_publishes_live_buf(tool, source, input, expected);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
fn batch_state() -> Value {
json!({ "children": [
{ "tool": "grep", "status": "success", "output": GREP_OUT },
Expand Down
29 changes: 29 additions & 0 deletions n00n-lua/tests/task_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const SUB_AGENT_ERROR_PREFIX: &str = "sub-agent error: ";

const TASK_TOOL: &str = "task";
const PROBE_TOOL: &str = "probe";
const TASK_PREVIEW_ID: &str = "task-preview";
const TASK_PREVIEW_EVENT_SEQUENCE: u64 = 0;
const TASK_PROMPT: &str = "do the thing";
const PLAIN_TEXT: &str = "plain text result";
const PROMPT_ERR_MSG: &str = "model exploded";
Expand Down Expand Up @@ -571,3 +573,30 @@ fn three_concurrent_tasks_return_results_without_nil_lifecycle_errors() {
assert_eq!(snap["sessions"], json!(3));
assert_eq!(snap["closed"], json!(3));
}

#[test_case::test_case(TASK_PREVIEW_ID; "running_task_publishes_live_preview")]
fn running_task_publishes_live_preview(preview_id: &str) {
let (reg, _host) = load_task_host();
let entry = reg.get(TASK_TOOL).expect("task registered");
let inv = entry
.tool
.parse(&task_input(SCENARIO_PLAIN, None))
.expect("parse failed");
let (tx, rx) = flume::unbounded();
let event_tx = n00n_agent::EventSender::new(tx, TASK_PREVIEW_EVENT_SEQUENCE);
let mut ctx = n00n_agent::tools::test_support::stub_ctx_with(
&AgentMode::Build,
Some(&event_tx),
Some(preview_id),
);
ctx.registry = Arc::clone(&reg);

smol::block_on(inv.execute(&ctx)).output.unwrap();

rx.drain()
.find_map(|env| match env.event {
n00n_agent::AgentEvent::LiveToolBuf { id, body } if id == preview_id => Some(body),
_ => None,
})
.expect("task did not publish its live preview");
}
1 change: 1 addition & 0 deletions plugins/bash/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ n00n.api.register_tool({
end

local buf, view = create_bash_view(command, ctx)
ctx:live_buf(buf)

local output_parts = {}
local has_output = false
Expand Down
4 changes: 3 additions & 1 deletion plugins/task/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ local function make_preview(ctx, description)

local elapsed = math.floor(progress.elapsed_ms / 1000)
local elapsed_str = n00n.ui.humantime(elapsed)
local header = { { description .. " · " .. elapsed_str, "bold" } }
local header = { { { description .. " · " .. elapsed_str, "bold" } } }
if progress.current_tool then
header[#header + 1] = { { "▸ " .. progress.current_tool, "bold" } }
elseif not progress.done then
Expand All @@ -152,6 +152,8 @@ local function make_preview(ctx, description)
view:toggle()
end)

ctx:live_buf(view.buf)

return { buf = view.buf, update = update }
end

Expand Down
6 changes: 3 additions & 3 deletions plugins/workflow/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -609,12 +609,11 @@ local function make_progress(ctx)
local function refresh_header()
local elapsed = math.max(os.time() - started_at, 0)
local header = {
{ state.name .. " · " .. state.phase .. " · " .. n00n.ui.humantime(elapsed), "bold" },
{ { state.name .. " · " .. state.phase .. " · " .. n00n.ui.humantime(elapsed), "bold" } },
}
if state.agents > 0 or state.cached > 0 then
header[#header + 1] = {
string.format("agents %d/%d cached %d", state.done, state.agents, state.cached),
"dim",
{ string.format("agents %d/%d cached %d", state.done, state.agents, state.cached), "dim" },
}
end
view:set_header(header)
Expand Down Expand Up @@ -807,6 +806,7 @@ local function handler(input, ctx)
}

local progress = make_progress(ctx)
ctx:live_buf(progress.buf)
progress.log("run_id " .. run_id)
local captured = {}
local saga = { compensations = {}, error_handlers = {} }
Expand Down
4 changes: 2 additions & 2 deletions site/docs/content/configuration/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,8 @@ n00n.setup({

| Field | Type | Default | Min | Description |
|-------|------|---------|-----|-------------|
| `edit_lines` | boolean | `false` | - | Provide the opt-in `edit_lines` tool. |
| `insert_lines` | boolean | `false` | - | Provide the opt-in `insert_lines` tool. |
| `edit_lines` | boolean | `true` | - | Provide the line-based `edit_lines` tool. |
| `insert_lines` | boolean | `true` | - | Provide the line-based `insert_lines` tool. |
| `multiedit` | boolean | `true` | - | Provide the `multiedit` tool. |

### `plugins.glob`
Expand Down
16 changes: 8 additions & 8 deletions site/docs/content/tools/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Read a file or directory. Returns contents with line numbers (1-indexed).

### `write` *(lua plugin)*

Write content to a file, replacing existing content. Creates parent directories. Always read first. Never create files unless necessary. Never proactively create docs (*.md, README) unless requested.
Write content to a file. Prefer edit or edit_lines for existing files.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
Expand All @@ -45,7 +45,7 @@ Write content to a file, replacing existing content. Creates parent directories.

### `edit` *(lua plugin)*

Replace an exact string match in a file. old_string must appear exactly once unless replace_all is true. Read file first. When copying from read output, exclude line number prefix (e.g. `42: `). Prefer over write for targeted changes. Use replace_all for renaming.
Replace exact string match in a file. `old_string` must match uniquely unless `replace_all` is true. Read file first.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
Expand All @@ -56,16 +56,16 @@ Replace an exact string match in a file. old_string must appear exactly once unl

### `multiedit` *(lua plugin)*

Make multiple find-and-replace edits to a single file atomically. Prefer over edit for multiple changes. Read file first. old_string must match exactly, including whitespace. Each edit must match exactly once unless replace_all. Edits applied in sequence. If any edit fails, none are written. Ensure earlier edits don't affect later edits.
Apply multiple non-adjacent string edits to a single file atomically. Applied in sequence; all roll back if one fails.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `edits` | array | yes | |
| `path` | string | yes | |

### `edit_lines` *(lua plugin, opt-in)*
### `edit_lines` *(lua plugin)*

Edit lines by number. Replaces lines from `start` to `end` (inclusive) with `new_string`. Use empty `new_string` to delete. Do not use with batch.
Replace lines from `start` to `end` (inclusive) with `new_string`. Use empty `new_string` to delete.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
Expand All @@ -74,9 +74,9 @@ Edit lines by number. Replaces lines from `start` to `end` (inclusive) with `new
| `new_string` | string | yes | |
| `end` | integer | yes | |

### `insert_lines` *(lua plugin, opt-in)*
### `insert_lines` *(lua plugin)*

Insert lines before a given line number. Lines at `line` and below shift down. Existing lines preserved. Do not use with batch.
Insert lines before `line` number. Existing lines shift down.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
Expand All @@ -86,7 +86,7 @@ Insert lines before a given line number. Lines at `line` and below shift down. E

### `glob` *(lua plugin)*

Find files by glob pattern. Respects .gitignore. Returns absolute paths sorted by modification time (newest first). Prefer speculative parallel searches over sequential glob+grep.
Find files by glob pattern. Respects .gitignore. Returns matching paths sorted by mtime.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
Expand Down
Loading