From e45980a28214cb0f081f3429ca5ffb45eec2133f Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 6 Aug 2026 08:38:43 +0800 Subject: [PATCH] feat: add cancellable sleep tool --- AGENTS.md | 1 + Cargo.toml | 3 +- README.md | 1 + docs/ARCHITECTURE.md | 2 + .../plans/2026-07-28-sleep-tool.md | 201 +++++++++++++++ .../specs/2026-07-28-sleep-tool-design.md | 41 +++ .../skills/about-picobot/references/tools.md | 4 + src/channels/feishu.rs | 1 + src/client/oneshot.rs | 1 + src/client/tui/components/chat_history.rs | 1 + src/session/turn.rs | 41 +++ src/tools/mod.rs | 3 + src/tools/sleep.rs | 238 ++++++++++++++++++ webui/package-lock.json | 4 +- webui/package.json | 2 +- webui/src/lib/TurnView.svelte | 2 +- 16 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-sleep-tool.md create mode 100644 docs/superpowers/specs/2026-07-28-sleep-tool-design.md create mode 100644 src/tools/sleep.rs diff --git a/AGENTS.md b/AGENTS.md index 2489473..978c9d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **Providers** are pure HTTP clients; no bus/session/channel awareness - **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs - **Tools** are executed by `AgentLoop`; they receive raw arguments and normally return text. Tools that produce model-consumable media use the structured `execute_with_media` side channel; model capability checks and provider content-block serialization stay outside tools +- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled` - **Stateful tools** receive `ToolExecutionContext`; browser automation maps each PicoBot dialog to an opaque agent-browser session, uses per-session serialization, and returns screenshots through structured media. Do not reintroduce Fantoccini, ChromeDriver, WebDriver, or model-controlled raw browser session IDs - **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets diff --git a/Cargo.toml b/Cargo.toml index 7a82c80..1790b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.4.0" +version = "1.4.1" edition = "2024" [dependencies] @@ -55,6 +55,7 @@ portable-pty = "0.9" [dev-dependencies] dotenv = "0.15" tower = "0.5" +tokio = { version = "1.53", features = ["test-util"] } [build-dependencies] zstd = "0.13" diff --git a/README.md b/README.md index f984f14..329be25 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ PicoBot 有两类记忆: | 工具 | 说明 | |------|------| | `calculator` | 数学表达式和统计计算 | +| `sleep` | 暂停当前 Agent 工具调用 0~86400 秒;可由用户停止,不用于持久调度 | | `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 | | `file_search` / `content_search` | 文件名和内容搜索 | | `bash` | 在 workspace 中执行 Shell 命令 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f73cd9..3a0f1e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -312,6 +312,8 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只 5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。 6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。 +内置 `sleep` 只暂停当前前台工具 Future,允许 0~86400 秒且不持久化;`/stop`、Scheduler/SubAgent 超时和 Supervisor shutdown 通过丢弃外层 Future 取消计时。Turn 进入 `Cancelled` 时必须把仍为 `Running` 的工具块同步归约为 `Cancelled`,避免终态快照继续显示工具执行中。超过 24 小时或需要跨重启的等待必须使用 Scheduler/后台任务。 + ### 新增 Provider 1. 实现 `LLMProvider`,保持其为纯 HTTP/API 适配器。 diff --git a/docs/superpowers/plans/2026-07-28-sleep-tool.md b/docs/superpowers/plans/2026-07-28-sleep-tool.md new file mode 100644 index 0000000..7709631 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-sleep-tool.md @@ -0,0 +1,201 @@ +# Sleep Tool Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a model-callable `sleep` tool that asynchronously waits for 0~86400 whole seconds and terminates cleanly when its Turn is cancelled. + +**Architecture:** A focused, stateless `SleepTool` validates its single argument and waits on one bounded Tokio timer. The existing default registry exposes it to agents, dropping the surrounding execution future cancels the timer, and terminal Turn reduction marks active tool blocks cancelled. + +**Tech Stack:** Rust 2024, Tokio timers and paused-time tests, existing `Tool`/`ToolResult` interfaces, Serde JSON. + +--- + +## Chunk 1: Tool And Registration + +### Task 1: Implement And Register `SleepTool` + +**Files:** +- Create: `src/tools/sleep.rs` +- Modify: `src/tools/mod.rs:1-52` +- Modify: `src/tools/mod.rs:74-90` +- Test: `src/tools/sleep.rs` + +- [ ] **Step 1: Declare the module and write failing metadata tests** + +Add `pub mod sleep;` and `pub use sleep::SleepTool;` to `src/tools/mod.rs`. Create `src/tools/sleep.rs` with a test module that imports `super::*`, `crate::tools::Tool`, `serde_json::json`, and `std::time::Duration`. Add a synchronous test asserting the name is `sleep`, the schema requires `seconds`, and its property type is `integer` with minimum `0`. + +- [ ] **Step 2: Write failing validation tests** + +Add a zero-second async test asserting exact successful output `Slept for 0 second(s).`. Add a table-driven async test for `{}`, negative, fractional, string, large float, and `86401`; assert each result is unsuccessful with empty output and a populated error. Add a parser boundary test: + +```rust +#[test] +fn accepts_24_hour_boundary() { + assert_eq!(parse_seconds(&json!({"seconds": 86_400})), Ok(86_400)); +} +``` + +- [ ] **Step 3: Write failing timer and cancellation tests** + +Add the following paused-clock elapsed test. Yield after every `advance` so expired timers are polled deterministically: + +```rust +#[tokio::test(start_paused = true)] +async fn waits_for_requested_seconds() { + let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(!handle.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(handle.await.unwrap().unwrap().success); +} +``` + +Add a 24-hour boundary test that advances to one second before the deadline, asserts the handle is unfinished, advances the final second, and asserts success. + +Add the cancellation test, which yields before aborting to ensure the timer has been registered: + +```rust +#[tokio::test(start_paused = true)] +async fn cancellation_drops_an_active_sleep() { + let handle = tokio::spawn(async { + SleepTool::new() + .execute(json!({"seconds": MAX_SLEEP_SECONDS})) + .await + }); + tokio::task::yield_now().await; + assert!(!handle.is_finished()); + handle.abort(); + assert!(handle.await.unwrap_err().is_cancelled()); +} +``` + +- [ ] **Step 4: Run the focused test target and confirm RED** + +Run: `cargo test --lib tools::sleep::tests` + +Expected: compilation fails because `SleepTool`, `parse_seconds`, and `MAX_SLEEP_SECONDS` are not defined. + +- [ ] **Step 5: Implement the minimal tool** + +Implement `src/tools/sleep.rs` with this shape: + +```rust +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::time::Duration; + +const MAX_SLEEP_SECONDS: u64 = 86_400; + +pub struct SleepTool; + +impl SleepTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SleepTool { + fn default() -> Self { + Self::new() + } +} + +fn parse_seconds(args: &serde_json::Value) -> Result { + let seconds = args.get("seconds") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "seconds must be a non-negative integer".to_string())?; + if seconds > MAX_SLEEP_SECONDS { + return Err("seconds must not exceed 86400 (24 hours)".to_string()); + } + Ok(seconds) +} + +#[async_trait] +impl Tool for SleepTool { + fn name(&self) -> &str { + "sleep" + } + + fn description(&self) -> &str { + "Pause the current agent execution for a specified number of whole seconds." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "seconds": { + "type": "integer", + "minimum": 0, + "maximum": MAX_SLEEP_SECONDS, + "description": "Number of whole seconds to wait, up to 24 hours." + } + }, + "required": ["seconds"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let seconds = match parse_seconds(&args) { + Ok(seconds) => seconds, + Err(error) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(error), + }); + } + }; + + tokio::time::sleep(Duration::from_secs(seconds)).await; + + Ok(ToolResult { + success: true, + output: format!("Slept for {seconds} second(s)."), + error: None, + }) + } +} +``` + +Keep the default `read_only`, `concurrency_safe`, and `exclusive` methods unchanged so a batch containing `sleep` executes sequentially. + +- [ ] **Step 6: Register the tool** + +In `create_default_tools`, add: + +```rust +registry.register(SleepTool::new()); +``` + +Place it with the other stateless core tools, immediately after `CalculatorTool`. + +- [ ] **Step 7: Run focused tests and confirm GREEN** + +Run: `cargo test --lib tools::sleep::tests` + +Expected: all sleep tests pass, including paused-time and cancellation cases. + +- [ ] **Step 8: Verify the complete Rust change** + +Run: `cargo test --lib` + +Expected: all library tests pass. + +Run: `cargo clippy --all-targets --all-features -- -D warnings` + +Expected: exits successfully with no warnings. + +Run: `cargo build` + +Expected: debug build succeeds, including embedded WebUI build handling. + +- [ ] **Step 9: Inspect the final diff** + +Run: `git diff --check && git status --short && git diff -- src/tools/sleep.rs src/tools/mod.rs docs/superpowers/specs/2026-07-28-sleep-tool-design.md docs/superpowers/plans/2026-07-28-sleep-tool.md` + +Expected: no whitespace errors; only the intended sleep implementation, Turn cancellation handling, public documentation, tests, and patch-version files are changed. Do not commit unless the user explicitly requests it. diff --git a/docs/superpowers/specs/2026-07-28-sleep-tool-design.md b/docs/superpowers/specs/2026-07-28-sleep-tool-design.md new file mode 100644 index 0000000..9922d2f --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-sleep-tool-design.md @@ -0,0 +1,41 @@ +# Sleep Tool Design + +## Goal + +Add a model-callable `sleep` tool that pauses the current agent tool call for a requested number of whole seconds. This first version only waits in process and does not schedule durable or background work. + +## Interface + +- Tool name: `sleep` +- Arguments: an object with one required `seconds` field +- `seconds` must be an integer from `0` through `86400` inclusive +- The maximum foreground wait is 24 hours +- `0` is valid and completes immediately +- Unknown object fields are ignored consistently with existing native tools + +Invalid, missing, negative, fractional, or values above `86400` return an ordinary unsuccessful `ToolResult`. A successful call returns `Slept for N second(s).`, with the requested duration substituted for `N`. + +## Implementation + +Create a stateless `SleepTool` in `src/tools/sleep.rs`. Its `Tool::execute` implementation validates `seconds`, waits on one bounded Tokio timer, and returns success after the full duration. The asynchronous timer does not block the Gateway runtime. + +Export the type from `src/tools/mod.rs` and register it in `create_default_tools`, making it available to the root agent and to constrained tool registries unless those registries explicitly filter it by name. + +The tool does not persist state, create a background task, or send messages. It retains the `Tool` trait's default non-concurrency-safe classification, so a model response containing `sleep` and other calls executes that batch sequentially. `/stop`, supervisor shutdown, scheduler timeout, and sub-agent timeout cancel work by dropping the surrounding agent execution future; dropping that future also drops the current Tokio sleep timer. When a Turn is cancelled, every still-running tool block is normalized to `ToolStatus::Cancelled` before publishing the terminal snapshot. + +The 24-hour cap bounds foreground resource retention. Longer or restart-durable waits must use Scheduler or background work rather than holding an interactive session worker. + +## Testing + +Unit tests cover the tool metadata and schema, immediate success for zero seconds, elapsed-time behavior using Tokio's paused clock, rejection of missing, negative, fractional, string, and over-24-hour values, acceptance of the 24-hour boundary, cancellation of an active sleeping task, and Turn cancellation normalization. + +Run the targeted tests, `cargo test --lib`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo build`. + +## Out Of Scope + +- Slash commands or direct user invocation +- Durable sleeps that survive process restart +- Delayed or scheduled message delivery +- A configurable duration limit + +This change increments only the product patch version. diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index 8b44c18..0201b99 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -198,6 +198,10 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅 用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn`、`write`、`read`、`kill`、`list`;`write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。 +## sleep — 前台等待 + +参数 `seconds` 接受 0~86400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。 + ## http_request / web_fetch — HTTP 和 Web 工具 `http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。 diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index 8e0f6ac..53a5936 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -2611,6 +2611,7 @@ fn render_feishu_turn(snapshot: &TurnSnapshot) -> String { let status = match status { ToolStatus::Running => "执行中", ToolStatus::Completed => "已完成", + ToolStatus::Cancelled => "已停止", ToolStatus::Failed => "失败", }; let mut section = format!("> 🔧 **{name}** · {status}"); diff --git a/src/client/oneshot.rs b/src/client/oneshot.rs index c032e4d..8fdf365 100644 --- a/src/client/oneshot.rs +++ b/src/client/oneshot.rs @@ -316,6 +316,7 @@ fn tool_status_name(status: ToolStatus) -> &'static str { match status { ToolStatus::Running => "running", ToolStatus::Completed => "completed", + ToolStatus::Cancelled => "cancelled", ToolStatus::Failed => "failed", } } diff --git a/src/client/tui/components/chat_history.rs b/src/client/tui/components/chat_history.rs index 3e48f1a..bd08aa9 100644 --- a/src/client/tui/components/chat_history.rs +++ b/src/client/tui/components/chat_history.rs @@ -89,6 +89,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { let status = match status { ToolStatus::Running => "执行中", ToolStatus::Completed => "已完成", + ToolStatus::Cancelled => "已停止", ToolStatus::Failed => "失败", }; lines.push(Line::from(Span::styled( diff --git a/src/session/turn.rs b/src/session/turn.rs index a102394..6f46dd9 100644 --- a/src/session/turn.rs +++ b/src/session/turn.rs @@ -56,6 +56,7 @@ pub enum TurnPhase { pub enum ToolStatus { Running, Completed, + Cancelled, Failed, } @@ -233,6 +234,18 @@ impl TurnControllerInner { return false; } self.text_segment_open = false; + if status == TurnStatus::Cancelled { + for block in &mut self.state.blocks { + if let TurnBlock::Tool { + status: tool_status, + .. + } = block + && *tool_status == ToolStatus::Running + { + *tool_status = ToolStatus::Cancelled; + } + } + } self.state.status = status; self.state.phase = TurnPhase::Finalizing; self.state.usage = usage; @@ -580,6 +593,34 @@ mod tests { assert_eq!(snapshot.revision, 1); } + #[test] + fn cancelling_turn_marks_running_tools_cancelled() { + let (controller, emitter, _receiver) = start(); + emitter + .emit(TurnEvent::ToolStarted { + iteration: 0, + call: ToolCall { + id: "sleep-call".into(), + name: "sleep".into(), + arguments: serde_json::json!({"seconds": 60}), + }, + }) + .unwrap(); + + assert!(controller.cancel(Some("stopped by user".into()))); + + let snapshot = controller.snapshot(); + assert_eq!(snapshot.status, TurnStatus::Cancelled); + assert!(matches!( + &snapshot.blocks[0], + TurnBlock::Tool { + id, + status: ToolStatus::Cancelled, + .. + } if id == "sleep-call" + )); + } + #[test] fn failure_is_published_as_structured_terminal_state() { let (controller, _emitter, _) = start(); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 041e9d8..186d47b 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -21,6 +21,7 @@ pub mod registry; pub mod reload_config; pub mod schema; pub mod send_message; +pub mod sleep; pub mod todo; pub mod traits; pub mod web_fetch; @@ -44,6 +45,7 @@ pub use pty::{PtyManager, PtyTool}; pub use registry::ToolRegistry; pub use reload_config::ReloadConfigTool; pub use send_message::SendMessageTool; +pub use sleep::SleepTool; pub use todo::TodoTool; pub use traits::{ OutboundDelivery, OutboundMessenger, Tool, ToolExecutionContext, ToolResult, @@ -73,6 +75,7 @@ pub fn create_default_tools( ) -> anyhow::Result { let registry = ToolRegistry::new(); registry.register(CalculatorTool::new()); + registry.register(SleepTool::new()); registry.register(FileReadTool::new()); registry.register(FileWriteTool::new()); registry.register(FileEditTool::new()); diff --git a/src/tools/sleep.rs b/src/tools/sleep.rs new file mode 100644 index 0000000..dffd6a4 --- /dev/null +++ b/src/tools/sleep.rs @@ -0,0 +1,238 @@ +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::time::Duration; + +const MAX_SLEEP_SECONDS: u64 = 86_400; + +pub struct SleepTool; + +impl SleepTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SleepTool { + fn default() -> Self { + Self::new() + } +} + +fn parse_seconds(args: &serde_json::Value) -> Result { + let seconds = args + .get("seconds") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "seconds must be a non-negative integer".to_string())?; + if seconds > MAX_SLEEP_SECONDS { + return Err(format!( + "seconds must not exceed {MAX_SLEEP_SECONDS} (24 hours)" + )); + } + Ok(seconds) +} + +#[async_trait] +impl Tool for SleepTool { + fn name(&self) -> &str { + "sleep" + } + + fn description(&self) -> &str { + "Pause the current agent execution for a specified number of whole seconds." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "seconds": { + "type": "integer", + "minimum": 0, + "maximum": MAX_SLEEP_SECONDS, + "description": "Number of whole seconds to wait, up to 24 hours." + } + }, + "required": ["seconds"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let seconds = match parse_seconds(&args) { + Ok(seconds) => seconds, + Err(error) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(error), + }); + } + }; + + tokio::time::sleep(Duration::from_secs(seconds)).await; + + Ok(ToolResult { + success: true, + output: format!("Slept for {seconds} second(s)."), + error: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::TurnEvent; + use crate::providers::ToolCall; + use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus}; + use crate::tools::Tool; + use serde_json::json; + use std::time::Duration; + + #[test] + fn exposes_sleep_metadata_and_schema() { + let tool = SleepTool::new(); + let schema = tool.parameters_schema(); + + assert_eq!(tool.name(), "sleep"); + assert!(tool.description().contains("current agent execution")); + assert!(tool.description().contains("whole seconds")); + assert_eq!(schema["type"], "object"); + assert_eq!(schema["required"], json!(["seconds"])); + assert_eq!(schema["properties"]["seconds"]["type"], "integer"); + assert_eq!(schema["properties"]["seconds"]["minimum"], 0); + assert_eq!( + schema["properties"]["seconds"]["maximum"], + MAX_SLEEP_SECONDS + ); + assert!(schema.get("additionalProperties").is_none()); + assert!(!tool.read_only()); + assert!(!tool.concurrency_safe()); + assert!(!tool.exclusive()); + } + + #[tokio::test] + async fn zero_seconds_returns_exact_success() { + let result = SleepTool::new() + .execute(json!({"seconds": 0})) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "Slept for 0 second(s)."); + assert_eq!(result.error, None); + } + + #[tokio::test] + async fn rejects_invalid_seconds() { + let invalid_args = [ + json!({}), + json!({"seconds": -1}), + json!({"seconds": 0.5}), + json!({"seconds": "1"}), + json!({"seconds": 18_446_744_073_709_552_000.0_f64}), + json!({"seconds": MAX_SLEEP_SECONDS + 1}), + ]; + + for args in invalid_args { + let result = SleepTool::new().execute(args).await.unwrap(); + assert!(!result.success); + assert!(result.output.is_empty()); + assert!(result.error.is_some()); + } + } + + #[test] + fn accepts_24_hour_boundary() { + assert_eq!( + parse_seconds(&json!({"seconds": MAX_SLEEP_SECONDS})), + Ok(MAX_SLEEP_SECONDS) + ); + } + + #[tokio::test(start_paused = true)] + async fn waits_for_requested_seconds() { + let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(!handle.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(handle.await.unwrap().unwrap().success); + } + + #[tokio::test(start_paused = true)] + async fn waits_up_to_24_hour_boundary() { + let handle = tokio::spawn(async { + SleepTool::new() + .execute(json!({"seconds": MAX_SLEEP_SECONDS})) + .await + }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(MAX_SLEEP_SECONDS - 1)).await; + tokio::task::yield_now().await; + assert!(!handle.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(handle.await.unwrap().unwrap().success); + } + + #[tokio::test(start_paused = true)] + async fn cancellation_drops_an_active_sleep() { + let handle = tokio::spawn(async { + SleepTool::new() + .execute(json!({"seconds": MAX_SLEEP_SECONDS})) + .await + }); + tokio::task::yield_now().await; + assert!(!handle.is_finished()); + handle.abort(); + assert!(handle.await.unwrap_err().is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn user_cancellation_stops_sleep_and_terminalizes_its_tool_block() { + let (controller, emitter, receiver) = + TurnController::start("cli:test:sleep", "assistant-message"); + emitter + .emit(TurnEvent::ToolStarted { + iteration: 0, + call: ToolCall { + id: "sleep-call".into(), + name: "sleep".into(), + arguments: json!({"seconds": MAX_SLEEP_SECONDS}), + }, + }) + .unwrap(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + + let handle = tokio::spawn(async move { + let tool = SleepTool::new(); + tokio::select! { + result = tool.execute(json!({"seconds": MAX_SLEEP_SECONDS})) => { + result.unwrap(); + false + } + _ = cancel_rx => { + controller.cancel(Some("stopped by user".into())); + true + } + } + }); + tokio::task::yield_now().await; + drop(cancel_tx); + + assert!(handle.await.unwrap()); + let snapshot = receiver.borrow().clone(); + assert_eq!(snapshot.status, TurnStatus::Cancelled); + assert!(matches!( + &snapshot.blocks[0], + TurnBlock::Tool { + id, + status: ToolStatus::Cancelled, + .. + } if id == "sleep-call" + )); + } +} diff --git a/webui/package-lock.json b/webui/package-lock.json index 1815367..21cb297 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index 39802de..8084cdd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.4.0", + "version": "1.4.1", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/lib/TurnView.svelte b/webui/src/lib/TurnView.svelte index 009e385..37437ca 100644 --- a/webui/src/lib/TurnView.svelte +++ b/webui/src/lib/TurnView.svelte @@ -13,7 +13,7 @@ } function toolStatus(value) { - return ({ running: "执行中", completed: "已完成", failed: "失败" })[value] || value; + return ({ running: "执行中", completed: "已完成", cancelled: "已停止", failed: "失败" })[value] || value; }